Accessing struct fields inside tests

Hello people, I have just begun working in Move and I am having trouble writing tests, due to the visibility constraints. When I started writing out the contract, the tests were within the same module and things worked fine. But now I need to refactor them into separate modules since the tests have become quite long. How am I supposed to access internal fields of a struct inside my tests, to write various assertion tests? How am I supposed to access the contents of the dynamic fields of a collection, such as a table? For example:

public struct OrdersRegistry has key, store {
        id: UID,
        orders: Table<vector<u8>, ID>
}

Please help me out guys.

1 Like

You can create accessors for your struct and mark them #[test_only], if you don’t want them to be publicly available.

Hi, you can use public(package) visibility for the structs to be only visible for the modules in the same package. And for accesing table contents you can use ‘table’ modules functions for example table::borrow(). There is also ‘friend’ keyword for package visibility but i am not sure its deprecated or not.

You have to write struct getter functions and mark them as #[test_only].
e.g.
#[test-only]
public fun get_id(registry: &OrdersRegistry): &UID { &registry.id }

When a Move module can’t be tested from outside — the missing-constructor trap

There’s a good thread here already on how to reach struct fields in tests (the answer being #[test_only] accessors). I ran into the opposite side of that problem recently and haven’t seen it written up, so here it is.

The short version: some modules can’t be exercised from an external test at all — not because your test is wrong, but because there’s no way to construct the type the entry functions demand. And you often don’t find out until you’ve already burned a lot of time trying.

Here’s how I hit it. I was building a test-generation tool and running it against real protocol code to see how it holds up — one of the targets was a staking/farm module from a published DeFi library(SuiTears). Its abort paths all looked reachable on paper: start-time checks, account-mismatch guards, insufficient-amount checks. Five abort sites, all things you’d want tests for.

The problem: the entry point needs a Farm object, and to build a Farm you need &CoinMetadata<StakeCoin>. CoinMetadata isn’t something a test can just make up — it comes from coin::create_currency, which requires a one-time witness. A test module can define its own OTW, so in theory the chain is:

OTW → create_currency → CoinMetadata → new_farm → new_account → stake → reach the deeper guards

In practice every approach I tried to assemble that chain failed to compile. And the module shipped without any #[test_only] constructor for Farm, so there was no shortcut. The existing test files in that codebase didn’t cover the farm module either — which, once I understood why, made complete sense. It wasn’t an oversight on my end or theirs; the type was just genuinely hard to construct in a test context, and nobody had added the helper that would make it possible.

So the five abort paths are, as the module ships, unreachable by any external test. Not “hard to test” — unreachable. That’s a different category, and it’s worth naming because the fix and the diagnosis are different from a normal coverage gap.

A couple of things I took from it:

This is a real testability property, not a skill issue. If you’re auditing or reviewing a module and its guards can’t be reached from a test, that’s a finding on its own — it means those paths ship unverified, and it means anyone who later wants to test them has to patch the upstream module first. Worth flagging in a review rather than quietly skipping.

The #[test_only] accessor pattern from the other thread has a constructor-side cousin. Everyone knows to add #[test_only] getters so tests can read internal state. The same courtesy applies to construction: if your type can only be built through an OTW-gated currency setup (or any similarly heavy chain), a #[test_only] constructor like

#[test_only]
public fun new_for_testing<...>(...): Farm<...>

is what lets external tests reach the guards at all. In the same codebase, other modules that did expose testing constructors (an oracle module had a new_..._for_testing, and an owner module took a generic witness) were straightforward to test from outside. The presence or absence of that one helper decided whether the module was testable at all.

A cheap pre-check saves the wasted rounds. Before writing (or generating) tests for a module, it’s worth scanning: for each struct an entry function requires, is there any constructor a test can reach — public or #[test_only]? If not, the abort paths behind it aren’t testable yet, and you know that up front instead of after several failed attempts.

I ended up baking that pre-check into the tooling I was working on, and I opened an issue on the upstream library suggesting the missing #[test_only] constructors — but the pattern itself is general, so I wanted to put it here for anyone hitting the same wall.

Curious whether others have run into modules that are structurally untestable as shipped, and how you handle it — flag-and-skip, fork-and-patch, or push the helper upstream?

Push upstream, and flag at the same time. A #[test_only] new_for_testing is a few lines, hard to argue against, and the only fix that lasts. On the audit side, “these five guards ship unverified and can’t be verified externally” is a finding on its own. Fork-and-patch is just for your own CI — temporary.

Add a distinction to your pre-check: “no constructor at all” is a blocker, “constructible only through a heavy chain” is a cost. Same severity means false positives.

One note: CoinMetadata is constructible in tests — via a separate test coin module plus #[test_only] init_for_testing. Building OTW {} in your test module and passing it in always fails. If that chain still doesn’t yield a Farm, the blocker isn’t metadata but whatever else the constructor demands. Worth ruling out before writing “unreachable,” since that’s a maintainer’s first pushback.

1 Like

Good point on the CoinMetadata chain — I didn’t try the separate test-coin-module path. My test attempts all built OTW inside the test itself, which is why create_currency always failed. If a dedicated test coin module with its own OTW
works, the real blocker in farm.move might be something downstream of CoinMetadata. I’ll re-check and update the SuiTears issue if the “unreachable” call was premature.

The severity split between “no constructor at all” and “constructible but heavy” is a fair distinction — my pre-check currently treats both the same, which does produce noise. Worth separating.

Appreciate the pushback.

1 Like

Shipped the split — blocker vs cost. CoinMetadata is cost now, not blocker. Commit be4bb7b if you want to see the diff.

Thanks for the nudge.