Skip to main content
This page shows the load-bearing patterns for writing FHE contract tests under Hardhat 3 with @cofhe/hardhat-3-plugin. Hardhat v2 counterpart: Hardhat Plugin → Testing.

Skeleton

test/Counter.test.ts
That’s the load-bearing shape. Everything below is what to add when the contract gets non-trivial.

Rules

1. async describe runs network.connect() once per file

Hardhat 3’s node:test runner supports top-level await inside the describe callback. Resolve the connection (and deploy mocks) at the top of the describe — every it inside the same block shares the same fresh deployment.
If you want a completely fresh mock deployment between tests, call await cofhe.mocks.deployMocks() inside a beforeEach.

2. Use cofhe.createClientWithBatteries() for most tests

It handles config + connect + self-permit in one call. The self permit lets decryptForView work immediately:
Drop down to cofhe.createConfig / cofhe.createClient only when you need to override defaults (e.g. a non-zero mocks.encryptDelay).

3. Assert plaintext with expectPlaintext whenever possible

Faster than decryptForView and needs no permit. Reserve the SDK path for tests where the SDK behavior itself is under test.

4. Call mock contracts directly through their descriptors

For state setup or assertions that don’t need the SDK, spread the descriptor into Viem:

5. Wrap noisy code in withLogs(name, fn) for debugging

When a test is misbehaving, scope mock logging to the suspicious block:

Common pitfalls

network.connect() returns a fresh deployment each call. If you await network.connect() once and share the result across describe blocks, those blocks share mock state — which is sometimes what you want and sometimes not. When in doubt, call network.connect() per describe.
Tests pass on the first op, then a second op reverts with ACLNotAllowed because the contract itself isn’t on the ACL. Toggle logging (await cofhe.mocks.enableLogs()) to see the missing grant — every op prints a line showing whether allowThis / allow was called.
The client is bound to whichever walletClient you passed to createClientWithBatteries. Calling the contract from a different wallet client (e.g. via vm.prank-style impersonation) will fail the ZK-verifier signature check — the input was signed for the original wallet, not the new caller.