Skip to main content

testing

Testing conventions for openzeppelin-contracts. Use when writing or modifying tests, mocks, or formal verification specs. Covers hardhat-exposed $-wrappers, when manual mocks are warranted, Hardhat+Chai patterns (loadFixture, shouldBehaveLike, multi-target loops), Foundry fuzz, Halmos symbolic execution, Certora rule-based verification, and the changeset rule.

Source facts

Repository
OpenZeppelin/openzeppelin-contracts
Last source activity
September 18, 2026 at 15:53
Detected SKILL.md language
English
Stars
27,256
Forks
12,399

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
testing
description
Testing conventions for openzeppelin-contracts. Use when writing or modifying tests, mocks, or formal verification specs. Covers hardhat-exposed $-wrappers, when manual mocks are warranted, Hardhat+Chai patterns (loadFixture, shouldBehaveLike, multi-target loops), Foundry fuzz, Halmos symbolic execution, Certora rule-based verification, and the changeset rule.
# Testing ## When to use each approach | Approach | Required when | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | Formal verification (Certora) | Contract includes a state machine or access-control rules complex enough that fuzzing won't cover the cases | | Symbolic execution (Halmos) | A property is expressible as a Foundry test with symbolic inputs — fast, no external credentials | | Fuzzing (Foundry) | Math-heavy code, data structures, complex invariants not suited for FV | | Edge unit cases (Hardhat) | All contracts: golden path + pathological/border cases. This is the baseline. | Default to Hardhat+Chai unit tests, add Halmos for invariants you can express symbolically, escalate to Certora when state-machine coverage requires it. A bug fix must add a minimal test that reproduces the bug, committed alongside the fix. The objective is 100% branch coverage per PR. ## Accessing internal functions: hardhat-exposed `$`-prefixed wrapper contracts are **auto-generated** by [hardhat-exposed](https://github.com/frangio/hardhat-exposed). The plugin creates a `$ContractName` for every contract in `contracts/`, with every internal `_function` re-exposed as an external `$_function`. Output goes to `contracts-exposed/` (gitignored). Use them directly: ```javascript const token = await ethers.deployContract('$ERC20', ['Name', 'SYM']); await token.$_mint(holder, 1000n); ``` **Do not write `$`-prefixed wrappers by hand.** ## Manual mocks in `contracts/mocks/` Manual mocks live in `contracts/mocks/` (not `test/`) and mirror the `contracts/` directory layout. Write a manual mock only when `hardhat-exposed` cannot give you what you need: - **Constructor-level setup** that auto-generation can't provide — e.g. `ERC721ConsecutiveMock` calling `_mintConsecutive` during construction. - **Multi-extension composition** to test how extensions compose — e.g. `ERC721Consecutive + ERC721Pausable + ERC721Votes`. - **Injected behavior** that overrides a hook to simulate an edge case — e.g. `ERC20Reentrant` firing a reentrant call inside `_update`. - **Adversarial behavior** with no analogue in the base — e.g. `ReentrancyAttack`, `ERC20ReturnFalseMock`. - **A plain public wrapper** for an internal operation where the auto-generated signature is unsuitable — e.g. `ERC20Mock.mint`. Even inside a manual mock, prefer the auto-generated `$_` functions over redeclaring the same exposure: ```solidity // prefer (reuses auto-generated exposure): await token.$_mint(holder, 1000n); // avoid (redundant — hardhat-exposed already provides this): contract ERC20Mock is ERC20 { function __mint(address account, uint256 value) external { _mint(account, value); } } ``` ## Hardhat + Chai (`.test.js`) **Fixtures**: use `loadFixture` from `@nomicfoundation/hardhat-network-helpers`. Declare the fixture with a named `function`, not an inline arrow — `loadFixture` keys its cache on the function reference, so an anonymous function (especially an inline `loadFixture(async () => {...})`) re-runs on every test instead of restoring the snapshot. ```javascript async function fixture() { const [holder, recipient] = await ethers.getSigners(); const token = await ethers.deployContract('$ERC20', [name, symbol]); await token.$_mint(holder, initialSupply); return { holder, recipient, token }; } beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); ``` **Structure**: `describe('ContractName') > describe('methodName') > it('description')`. **Shared behavior**: reuse via `shouldBehaveLike*` functions in sibling `.behavior.js` files. Mount with this-context state set in the fixture: ```javascript const { shouldBehaveLikeERC20 } = require('./ERC20.behavior'); // ... shouldBehaveLikeERC20(initialSupply, { forcedApproval }); ``` **Multi-target loops**: when the same suite should run against several wrappers (e.g. the auto-generated `$ERC20` and a manual `$ERC20ApprovalMock`), iterate at the top: ```javascript const TOKENS = [{ Token: '$ERC20' }, { Token: '$ERC20ApprovalMock', forcedApproval: true }]; for (const { Token, forcedApproval } of TOKENS) { describe(Token, function () { /* … */ }); } ``` **Assertion style**: - `bigint` literals (`100n`) for uint values — not `ethers.BigNumber`. - Custom errors: `.revertedWithCustomError(contract, 'ErrorName').withArgs(...)`. - Panics: `.revertedWithPanic(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW)`. - Events: `.emit(contract, 'EventName').withArgs(...)`. - Balance changes: `.changeTokenBalance(token, account, delta)` / `.changeTokenBalances(token, [a, b], [d1, d2])`. ## Foundry (`.t.sol`) Foundry tests live next to their Hardhat counterparts in `test/`. Under HH3 they are picked up by `npm test` (via the `@nomicfoundation/hardhat-mocha` solidity runner) alongside the JS tests. Standalone `forge test` still works but uses forge defaults for fuzz. Two distinct purposes: **Fuzzing**: function name starts with `test`, parameters are typed. Foundry generates random inputs. ```solidity function testFuzzAdd(uint256 a, uint256 b) public pure { vm.assume(a < type(uint256).max - b); // restrict if needed assertEq(Math.add(a, b), a + b); } ``` Use `assertEq`, `assertGt`, etc. from `forge-std/Test.sol`. Fuzz settings live in `hardhat.config.ts` under `test.solidity.fuzz` (`runs = 5000`, `maxTestRejects = 150000`) and are applied when running via `npm test`. Standalone `forge test` uses forge defaults. **Symbolic execution (Halmos)**: function name starts with `symbolic` or `testSymbolic` — that's what the CI `halmos` job matches (`--match-test '^symbolic|^testSymbolic'`). These run as regular Foundry fuzz tests as well. ```solidity function testSymbolicTransfer(address to, uint256 amount) public { // Halmos treats inputs as fully symbolic (all values). // Use vm.assume(...) for preconditions. } ``` Halmos runs on every PR, needs no credentials. Reach for it when a property can be expressed as a Foundry test. **Pragma note**: Foundry compiles with `solc 0.8.31` (`foundry.toml`); contract pragmas in `contracts/` may still be `^0.8.20`. New `.t.sol` files should match the contract's pragma minimum unless they specifically need a newer feature. ## Certora (rule-based formal verification) [Certora](https://certora.com) specs live in `fv/specs/` as `.conf` + `.spec` pairs. CI runs one job per affected config, on any PR whose diff reaches a spec, a harness, a contract one of them verifies, or an `fv/diff` patch applied to one -- no label needed. Label a PR `formal-verification-force-all` to run every config instead. Requires a `CERTORAKEY`, and the `certora` environment gates the job behind a manual approval. Selection runs the base branch's copy of `scripts/list-dependencies.js` over the PR's files, so a PR cannot change which configs it is checked against. The matrix job names carry their config, so `verified` is the aggregate job to require in branch protection. Deleting a `.conf` fails selection, since a config that no longer exists cannot be selected and its verification would just disappear; label the PR `formal-verification-allow-removal` when that is intended. Local workflow: ```bash make -C fv apply # apply harness patches first node fv/run.js AccessControl # runs fv/specs/AccessControl.conf node fv/run.js --all # runs every spec ``` Use Certora when a property is too complex for Halmos, or when a state machine needs deeper rule coverage than fuzzing provides. ## Changesets Every PR that changes contract behavior needs a changeset. Skip for NatSpec-only edits, internal refactors with no user-visible effect, or pure repo plumbing. ```bash npx changeset add ``` The generated file in `.changeset/` looks like: ``` --- 'openzeppelin-solidity': minor --- `ComponentName`: One-sentence description starting with a backtick-quoted contract or component name. ``` - `patch` — bug fixes. - `minor` — new features. - One sentence. No bullet points, no multi-paragraph prose. The release pipeline composes these into `CHANGELOG.md` automatically. ## CI matrix you should keep in mind | Job | What it runs | Triggered by | | --------------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | | `lint` | `npm run lint` | Every push/PR | | `tests` | `npm test` (JS + Foundry via HH3) + inheritance, pragma, generation | Every push/PR | | `tests-upgradeable` | Transpile then re-run the test suite + storage-layout diff | Every push/PR | | `coverage` | `npm run coverage` → codecov | Every push/PR | | `slither` | Slither static analysis for common vulnerabilities | Every push/PR | | `halmos` | `halmos --match-test '^symbolic\|^testSymbolic'` | Every push/PR | | `formal verification` | Certora, one job per affected config (or all, with the force label) | PRs touching a spec, harness, patch or verified contract |
View on GitHub