T12 · Testing and Deployment
How do you find problems before real money is at stake?
- Capabilities practiced
- Builder
- Hands on
- Fork mainnet locally, interact with a real protocol, and complete a full test path.
- AI Lab
- Have AI generate test cases for your contract, then count which branches they cover and which edge cases they miss.
A Real-World Problem
You write a contract that accepts a stablecoin deposit, puts it into a lending protocol, and gives the user a receipt token.
You test carefully: a fake token, a fake lending protocol, 34 test cases, 97% coverage, all green.
You deploy to mainnet. The first real transaction reverts.
After an afternoon of investigation, you learn that this real stablecoin rejects a second approval when an existing allowance is nonzero. Your fake token did not behave that way because you implemented the standard, while the real token does not follow it exactly (the class of traps from T9).
You fix it and redeploy. The second transaction reverts. This time the real lending protocol has a minimum deposit for this asset, below which it rejects deposits. Your fake protocol had no such rule.
After your third deployment comes the hardest discovery: the contract’s administrator authority still belongs to the temporary address used by your deployment script. You wrote the transfer step, but nothing verified that it succeeded.
These problems share a cause:
They are not errors in your code. They are errors in your assumptions about the world, and your tests were built from those same assumptions.
97% coverage proves that your code ran. It does not prove any assumption true.
So how do you test against the real world instead of the world you imagined?
Thought Experiment
Think of prelaunch preparation as flight training, with four levels.
Level one: work it out on paper. Given wind speed, weight, and runway length, calculate the takeoff distance. Fast and cheap, it checks your formula. It cannot verify where those numbers came from. Use the wrong wind speed and a correct calculation still ends badly.
Level two: fly in a simulator. Build the airplane, runway, and tower, then run the whole procedure. You can now find problems between components, such as flaps failing after the landing gear retracts. But you built the simulated runway; it lacks potholes on the real one.
Level three: bring the actual airport into the simulator. Instead of building your own runway, import the layout, terrain, and navigation equipment of a real airport and fly there.
This is the heart of the chapter. Your code still runs locally, but it interacts with real current state: real tokens, protocols, parameters, and quirks. All three opening problems become visible here when their relevant steps run.
Level four: fly once for real, but carry sandbags. Rehearse the entire deployment flow in an environment without real money, including deployment, verification, and authority transfer.
The four levels have clear jobs:
- The first two validate your logic.
- The third validates your assumptions.
- The fourth validates your process.
These are different kinds of failure. No single method covers them all.
One more point matters more than any level:
Every entry point to a contract is public. Therefore, whether it rejects correctly matters more than whether it executes correctly.
There is one expected successful path, and you will test it. There are dozens of paths that must be rejected. Omit one and anyone can call that opening directly.
Your Decision
Your contract interacts with a real protocol. How do you test before launch?
What Happens
The four options are not a ranking. They cover two independent dimensions:
| Logic branch coverage | Dependency realism | Speed | Which opening problem can it reveal? | |
|---|---|---|---|---|
| All mocks | Highest | Lowest | Fastest | None |
| Deploy real dependencies locally | High | Medium | Medium | Sometimes the first |
| Import real state | Medium | Highest | Slow | First and second |
| Manual testnet run | Lowest | High | Slowest | Third |
The key sentence hidden in the table:
Coverage measures how much of your code executed, not how many assumptions were tested.
A 100%-coverage suite built entirely on mocks may know nothing about real behavior. A single path running against real state may cover only 20% of branches but prevent a loss. The answer is to assign each layer a job:
- Use mocks for logic and rejection paths because they are fast enough for many cases.
- Use real state for integration because only it tests assumptions.
- Rehearse the complete deployment process because deployment is code few people test.
One further implication deserves its own line: the rejection case you never wrote is the public entry point someone else will call.
Build a Model
A Launch Pipeline
- Write failure cases
- Logic tests (mocks)
- Integration tests (real state)
- Fuzzing and invariants
- Rehearse the deployment script
- Full testnet run
- Mainnet
Notice the first step: write failure cases before success cases. This is about sequence, not style. After writing success cases, people often feel finished.
Rejection Checklist: One Test per Constraint
Open the contract and list every place it rejects a condition. Give each its own test.
| Contract constraint | What it prevents | Required failing test |
|---|---|---|
| Administrator only | Anyone taking control | Call from an ordinary address; assert rejection |
| Initialize only once | Front-running or repeated initialization | Initialize twice; assert the second is rejected |
| Amount greater than zero | Zero-amount operations corrupting accounting | Pass 0; assert rejection |
| Sufficient balance | Excess withdrawal | Withdraw one unit more; assert rejection |
| Sufficient allowance | Spending someone else’s funds | Transfer without approval; assert rejection |
| No operations while paused | Broken emergency stop | Call after pausing; assert rejection |
| Deadline not passed | Executing expired orders | Advance time past deadline; assert rejection |
| Reentrancy protection | Reentry from a callback | Use a mock contract that calls back; assert rejection |
The last row is special: write a malicious contract specifically as a test tool. Its sole job is to attempt something forbidden when called back. T28 covers the full reentrancy model, but the test belongs here.
Two easily missed points:
- Assert the intended rejection, not just any error. Check the specific error type or message. Otherwise, a test remains green when the contract fails for an unrelated reason; false-green tests are common.
- Check that state stayed unchanged. A revert should undo every state change in the transaction. An extra assertion on critical state can catch subtle mistakes.
Testing Real State: Three Essentials
First, pin the block number. When pulling state from a real network, specify a block in configuration. Otherwise, tests depend on live state that changes every few seconds. A test that was green today turns red next week, and you can spend days debugging a problem outside your code.
Second, prepare identity and balances. Your test account has no funds in real state. There are two approaches:
- Impersonate a real address holding much of the target asset. Most test frameworks can “send a transaction as any address.”
- Write storage directly to give yourself a balance. It is faster but requires knowing the balance slot (see the storage layout in T8).
Third, assert real outcomes, not merely the absence of an error. “The transaction did not revert” tells you little. Assert how your balance changed, which receipt tokens you received, and what the protocol’s accounting became.
A complete integration test looks like this:
1. Import state from a real network at a fixed block
2. Deploy your contract
3. Give the test account real assets in the local environment
4. Record all relevant pre-interaction balances and state
5. Call your contract through the full business path
6. Assert your balance, counterparty balance, receipt quantity, and protocol accounting separately
7. Run the reverse path (withdraw or redeem) and assert that you return to the startStep 7 is easiest to skip and often where the problem hides. Being able to deposit does not mean being able to withdraw. Only trying it proves that path.
Fuzzing and Invariants: Executable “For Every Input” Claims
An ordinary test says “for this input, expect that output.” A randomized test reverses the question: for any input, a property must always hold.
These properties fit many contracts holding funds:
Assets actually held by the contract ≥ sum of all user accounting balances
Sum of all holder balances = total supply
After any sequence of operations, an account can withdraw no more than it deposited
A “deposit then immediately withdraw” sequence is no better than doing nothing
A parameter reserved for the administrator remains unchanged after 10,000 random callsThe last is underrated: point a random caller at every public entry point for ten thousand calls, then check the invariant. It is cheap and often finds sequences you never considered.
Two practical details:
- Make failures reproducible. Record the seed and input sequence that caused the failure, then turn them into an ordinary permanent test.
- Define invariant scope carefully. An invariant about contract balance and accounting may be affected by direct transfers that bypass your entry points. Adjust the property or acknowledge a real defect.
Deployment Is Code That Few People Test
Deployment scripts may be the project’s only code that runs once, cannot be corrected afterward, and has no tests. The third opening problem occurred here.
A sound deployment script must do the following:
| The script must... | Without it... |
|---|---|
| Read every parameter from configuration | Switching chains introduces quiet errors |
| Record each resulting address in a file | No one can later identify which address has which version |
| Assert onchain state immediately after deployment | Incorrect permissions or parameters go unnoticed for months |
| Transfer authority as a separate step and assert it | Control remains with a temporary private key |
| Be repeatable and idempotent | Midway failure forces a full restart or duplicate deployment |
| Output everything needed for source verification | No one can compare onchain code with the repository |
The third row is most important. The last part of the deployment script should be assertions, not “deployment complete”:
assert contract owner == configured multisig address
assert contract admin != deployer address
assert critical parameters == configured values
assert contract is paused (if launch begins paused)
assert external dependency address == configured address, and code exists at that addressThe final check is easy to miss: an external address you supplied may contain no contract on this chain. Deployment accepts it quietly, and the first user transaction fails later.
One more process rule: rehearse the deployment script against local real-state tests first. Run the same configuration and script through deployment and assertions on imported real state. That can catch the three most expensive errors—configuration, addresses, and authority—before spending money.
What It Is Called
Tests one function or module’s logic, replacing external dependencies with mocks.
It is fast enough for hundreds of cases. Its blind spot is that every mock reflects your own assumptions. In contracts, its greatest value lies in testing every rejection path, not just success.
A fake dependency whose behavior you control.
Its cost is replacing “how does the real dependency behave?” with “how do I think it behaves?” Every mismatch between the answers is an area your tests cannot illuminate.
Tests several real components together to validate their interfaces and order of interaction.
For contracts, the key is not merely “multiple components,” but whether they are real. A test assembling many mocks is still a larger unit test.
A local chain starts from the state of a real network at a fixed block, fetching data from that network when needed.
This is the chapter’s most important capability. It lets you interact with real tokens, protocols, and liquidity without spending on mainnet. One hard rule: pin the block number, or results drift over time.
Repeatedly calls functions with random inputs to find inputs that break them.
An ordinary test asks, “Is this input correct?” Fuzzing asks, “Is there an input that breaks it?” Once you find one, turn it into a permanent test first.
A property that must hold after every operation sequence, such as “assets held by the contract are at least the sum of user balances recorded.”
Invariant testing expands fuzzing across large random sequences of calls to all public entry points, checking the property afterward. It often finds surprising call orders.
Repeatable code for deployment, configuration, authority transfer, result assertions, and address recording.
A simple standard for judging one: does it end with assertions? A script that only prints “deployment complete” has not verified anything.
Hands-on
The lab costs no real money: a local fork reads real state into your computer, but all your transactions stay local. The last two steps use a testnet with faucet tokens. Generate a separate key for this exercise; never use a wallet with real assets.
The commands below are generic. Test frameworks have different flags, so check help for each command before using it.
Start a fork and pin its block number.
<your framework's fork flag> --fork-url <your archive RPC URL> --fork-block-number <a chosen block number>Write that number in configuration or an environment variable. Do not leave it blank, or each run may use different state.
Verify the fork: read the code length at a real contract address and confirm it is nonzero. A zero length means the fork is not working; later tests would run on an empty chain.
Give your test account real assets within the fork.
Choose one method:
Method one: impersonate a holder
Find a real address with a large amount of your target token
Use your framework's ability to simulate transactions from any address
Transfer some funds to your test account
Method two: write storage directly
Locate the storage slot for the balance mapping (T8 explained the layout)
Use your framework's ability to write any storage slotThe first is more realistic, the second faster. For your first attempt, use the first; it also verifies that the fork is connected to real state.
Write three failing cases before your first successful case.
Before testing the happy path, write:
Case 1: call with amount 0; assert the intended rejection and unchanged state
Case 2: call without an allowance; assert the intended rejection
Case 3: call an administrative function as a nonadministrator; assert the intended rejectionAssert the specific error for each, not just “failed.”
Then use the rejection table above: one case for every constraint in your contract. That alone gives your contract better rejection coverage than many launched contracts.
Complete a real end-to-end interaction.
Follow the seven steps in “Build a Model”: record pre-interaction balances and state, call your contract, assert each outcome, and run the reverse path back to the start.
Do not skip step seven: withdraw what you deposited. Many problems appear only on redemption.
This may fail once or several times. Each failure reveals a real-world quirk before launch, which is the value of the lab. Record each one for your prelaunch checklist.
Run coverage, then do not trust the number.
Record the coverage report. Then perform a more useful count:
Open the contract and count every “reject when condition is not met” rule.
Count tests that assert rejection.
The two counts should be close.If rejection cases are far fewer than rejection conditions, high coverage is misleading: those branches were passed through, not verified.
Add one invariant and run ten thousand random calls.
Choose the most important property, often:
Assets actually held by the contract ≥ sum of all user accounting balancesUse your framework’s randomized calls over all public entry points, with random arguments and order. Check the property after each round.
Turn any counterexample into an ordinary test. The output of fuzzing is not “it ran”; it is the input sequence it discovered.
Write a deployment script and rehearse it on the fork.
Follow the deployment table above and end with assertions. Run the script once on the fork.
Include both authority assertions: owner equals the configured address and differs from the deployer. That catches the third opening failure.
Run the entire process once on a testnet.
Use the same script and configuration format with testnet values. Deploy, run assertions, and verify source code.
Get test tokens from a faucet; no real money is involved.
Afterward ask which steps exposed something the fork did not. Usually there are one or two, such as a dependency missing on testnet. That is why this layer exists.
AI Lab
Ask in three steps. The second and third produce the real value.
Step one:
Here is my contract source. Generate a test suite for it. First list every condition that
must cause rejection and give each a test asserting rejection. Then write success-path tests.
Explain what each case checks.
Step two:
Compare the tests you just wrote against my contract line by line in a table.
For every require, revert, or permission check, which test covers it?
List checks with no corresponding test separately.
Step three:
This contract integrates with a real external protocol, but my tests use a mock.
Which classes of difference between mock and reality could make tests pass but mainnet fail?
Give one concrete example per class and explain how to verify it on a fork.The model tends to produce many tests quickly, but its distribution can be badly skewed: detailed success paths and few rejection paths, much like code in its training data. Judge the first answer by its proportion of rejection tests, not its raw count.
Step two forces a useful comparison. Asking the model to map every source check to a test is more reliable than asking vaguely what it missed. The table becomes a concrete list of tests to add.
Step three is especially valuable and suits models well: synthesis and reasoning rather than precise recall. You saw the same boundary in the AI Labs of T9 and T11: verify facts requiring exact recall, and use a model to enumerate possible patterns.
You must do one final step yourself: test each claimed difference against the real dependency on a fork. A model’s claim about a stablecoin’s unusual approval behavior may be right or may confuse it with another token. The fork gives you a referee without risking money.
Verify these points yourself after using AI
- You verified every test-framework API and assertion method it used in help or documentation; models invent plausible helper names
- Count how many generated tests assert rejection; if it wrote only success paths, ask it to redo the suite
- Compare every rejection condition in your contract with the tests and add each missing case yourself
- Does it assert a specific error type or merely “the transaction fails”? The latter causes false-green tests
- Does it assert that state is unchanged after rejection?
- Are boundaries covered: 0, maximum values, tokens with 0 and 18 decimals, repeated calls in one block, and a balance exactly equal to the needed amount?
- Do mock contracts behave like real dependencies? Verify that against the fork
- If it wrote fork tests, did it pin the block number?
- Do not trust a coverage number it reports; run the coverage tool yourself
Real-World Case
An upgradeable contract exposed an initializer on its implementation without preventing repeated or unauthorized calls. Tests invoked it only during expected deployment, so they passed. Onchain, someone called the same function directly and made themselves administrator.
The shared pattern is testing only the intended caller. One test asserting that initialization can happen only once and only through the authorized path can catch this.
This is the opening scenario. The mock follows the standard; the real token differs in approval and return behavior.
A broader example is return values. The standard says a transfer function returns a boolean, but some early tokens return nothing. A standard-assuming caller fails while decoding that result, and the mock never reveals it.
As T9 explained, the testing remedy is simple: run against the real token on a fork and the difference appears.
A deployment script includes “transfer owner to multisig,” but that step fails quietly due to a bad argument. The script keeps going and prints “deployment complete.” Weeks later, an authority check reveals that a hot wallet private key still controls the protocol. Anyone holding that key could have changed parameters throughout that period.
End a deployment script with assertions, not logs. Assert authority twice: who the owner is, and that the owner is not the deployer.
All handwritten tests pass for a contract converting between shares and assets. After hundreds of thousands of random runs, fuzzing finds a tiny input whose division rounds in the user’s favor. Repeated deposits and withdrawals can slowly drain the protocol. Each loss is negligible, but the cycle can repeat indefinitely.
Rounding direction is hard for humans to exhaust but easy for machines to explore. This is a classic invariant-testing result: “deposit and withdraw is no better than doing nothing” is the property, and random sequences find a counterexample.
Change One Variable
Tests that pass today turn red next week, even though your code did not change. Perhaps pool liquidity changed, governance altered a parameter, or the large holder you impersonated moved their tokens.
This is painful to debug because the failures point toward your code even though the cause lies elsewhere. One configuration line can prevent days of investigation.
Tests run against logic that no longer exists on mainnet. They pass while live behavior differs.
Treat the fork block as a maintained dependency: periodically move it forward, run tests, and investigate new failures. A failure can signal that something you rely on changed.
At a deeper level, upgradeability of external dependencies belongs in your risk model. T28 and T29 expand on this.
This is common during testnet rehearsal. Many protocols deploy only to mainnet, or have incomplete testnet versions.
You can deploy a simplified copy on testnet just for process rehearsal, leave functional testing to the fork, or rehearse only deployment and authority without business logic on testnet.
The third is often most practical: each layer should do the job it does best. Testnet verifies process, not the real protocol’s behavior.
This is possible: every branch ran, but no test checks whether forbidden behavior was rejected for the right reason.
The coverage report then becomes actively misleading, encouraging you to stop asking questions.
A better indicator is the ratio between rejection conditions and tests asserting rejection. It is imperfect but at least measures something relevant.
Takeaway Questions
What problem does it solve? This chapter asks how to find errors while the cost is still low. For contracts, that is urgent because after deployment, fixing a problem may be impossible rather than merely expensive.
Who bears the risk? Users bear all risk from untested rejection paths and cannot know in advance. “We tested it” is not a useful public claim. Say which rejection conditions were tested, which real state was used, and what the deployment script asserted.
Who loses when AI is wrong? The AI Lab asks a model to write tests, but a suite missing a key rejection case can be more dangerous than no suite, because it quiets doubt. Run each generated case and confirm the intended error. Responsibility stays with whoever deploys.
Which decisions need a human in the loop? Deployment and authority transfer are clear examples. The script may execute automatically, but a human must review the configuration. Assertions let machines verify decisions you have made, not make those decisions for you.
Chapter Check
Coverage says a line executed, not that its intended behavior was verified.
Two common gaps: branches were passed through incidentally, with no assertion that rejection worked; or all external dependencies were mocks written from your assumptions. Coverage only proves your code agrees with those assumptions.
A more useful measure compares the number of rejection conditions in the contract with tests that assert rejection. They should be close.
They turn assumptions about external dependencies into observed behavior.
Every unit-test mock follows your understanding. Real tokens and protocols have behavior beyond written standards: unusual approval requirements, minimum amounts, return-value differences, pause switches, and fees. These appear against real state.
One hard rule: pin the fork block number. Otherwise, your suite can turn red at random as time passes.
Every contract entry point is public and accepts calls from anyone with arbitrary arguments. There is one normal path but many paths that must reject.
Order matters. After writing success tests, people often feel finished and add only obvious rejection tests. Start with a complete rejection checklist, then add success paths.
List every “reject if condition is not met” in the contract. Give each a test for the specific error and confirm critical state stayed unchanged.
Deployment is often the one piece of project code that runs once, cannot be fixed afterward, and is rarely tested. A step can fail quietly while the script still prints success.
Final assertions should at least confirm that owner is the configured multisig rather than the deployer, critical parameters match configuration, and each external dependency address contains code.
Rehearse the same script and configuration on a fork first. That catches expensive configuration, address, and authority errors without spending money.
There is no prescribed answer. Ask:
- Does every rejection condition have a test asserting the specific error?
- Does a test confirm state remains unchanged after rejection?
- Is there a callback-capable malicious contract for testing?
- Do integration tests use real state with a fixed block number?
- Did you test the reverse path: can deposited assets be withdrawn?
- Has at least one invariant survived ten thousand random calls? Were counterexamples turned into cases?
- Are all deployment parameters read from configuration, and does the script end with assertions?
- Are both authority assertions present: who owner is and that owner is not the deployer?
- Did you rehearse deployment on a fork and run it fully on testnet?
- Did you record source-verification information and deployed addresses?
Points 6 and 8 are often skipped, and they are the two this chapter most wants you to remember.
Key idea
Testing against a fork of mainnet state is closer to reality than any isolated unit test.