To test a smart contract upgrade, deploy the old version behind a proxy, put real state into it, upgrade to the new version, and check that the state and behavior survived. Around that core test you add baseline tests, storage layout checks, compatibility checks, gas checks, an audit and a rehearsal. The nine steps below cover all of it, with a checklist at the end.
Why upgrades need their own tests
An upgrade changes code that already holds user funds. A bad one can break withdrawals or open a hole in a contract that had been fine for months. The new version passing its own tests tells you nothing about whether it works on top of the old state.
Two public incidents show what this looks like:
- Nomad bridge, August 2022. More than $190 million was drained after a routine upgrade in June set a trusted-root value to zero, which made every message count as valid. Halborn’s analysis walks through it. A test that pushed an unproven message through the upgraded contract would have checked exactly this.
- Audius, July 2022. Audius’s own post-mortem describes a bug in the proxy and initialization setup that let an attacker call initializer functions again and take over governance. Storage layout and initializer behavior are exactly what steps 2 and 3 below test.
The examples use Hardhat 2, ethers v6 and the @openzeppelin/hardhat-upgrades plugin. Install it with npm install --save-dev @openzeppelin/hardhat-upgrades and add require('@openzeppelin/hardhat-upgrades'); to hardhat.config.js.
1. Baseline tests on the current version
Before upgrading, make sure the live version has solid coverage:
- Unit tests for each function
- Integration tests for complex interactions
- Fuzz tests for edge cases
Without these you can’t tell a regression from a behavior that was always there. If the baseline suite doesn’t exist yet, the automated testing setup is the place to start.
2. Check the storage layout
Most upgrade bugs come from storage. A proxy keeps state in its own storage, and the new implementation reads that storage by slot. Reorder, retype or insert variables and the new code reads the wrong data. The OpenZeppelin rules are simple: add new variables only at the end, and never reorder or remove existing ones.
The plugin checks this for you. Run validateUpgrade in a test so a bad layout fails the build:
it('rejects a reordered storage layout', async () => {
const V1 = await ethers.getContractFactory('BoxV1');
const Bad = await ethers.getContractFactory('BoxV2Bad'); // swaps two variables
await expect(upgrades.validateUpgrade(V1, Bad)).to.be.rejected;
});
upgradeProxy runs the same validation, but a dedicated test documents the rule and fails earlier.
3. Test the initializer
Implementation contracts must not be initializable by strangers, and the proxy must not be initializable twice. Test both:
it('cannot be initialized twice', async () => {
const [owner] = await ethers.getSigners();
const V1 = await ethers.getContractFactory('BoxV1');
const proxy = await upgrades.deployProxy(V1, [42, owner.address]);
await expect(proxy.initialize(1, owner.address)).to.be.reverted;
const implAddress = await upgrades.erc1967.getImplementationAddress(
await proxy.getAddress()
);
const impl = await ethers.getContractAt('BoxV1', implAddress);
await expect(impl.initialize(1, owner.address)).to.be.reverted;
});
The second check passes only if the implementation calls _disableInitializers() in its constructor. The 2017 Parity multisig freeze, where a user took ownership of an uninitialized library contract and destroyed it, is the textbook case. If the new version adds a reinitializer(2) function, test that it runs once and only for the right caller.
4. Simulate the upgrade with real state
Deploy V1 behind a proxy, create state, upgrade, and check the state:
it('keeps user balances after the upgrade', async () => {
const [owner, user] = await ethers.getSigners();
const VaultV1 = await ethers.getContractFactory('VaultV1');
const vault = await upgrades.deployProxy(VaultV1, [owner.address]);
await vault.waitForDeployment();
const proxyAddress = await vault.getAddress();
await vault.connect(user).deposit({ value: ethers.parseEther('1') });
const VaultV2 = await ethers.getContractFactory('VaultV2');
const vaultV2 = await upgrades.upgradeProxy(proxyAddress, VaultV2);
expect(await vaultV2.getAddress()).to.equal(proxyAddress); // same proxy
expect(await vaultV2.balanceOf(user.address)).to.equal(
ethers.parseEther('1')
);
});
The test deposits on V1, upgrades, and reads the balance on V2. Repeat the pattern for every critical state variable, not only balances. Check that the proxy address did not change, since that is what users and integrators know.
5. Check backwards compatibility
An upgrade shouldn’t break what already integrates with the contract. Test that:
- Old function calls still work with the same signatures
- Events are emitted with the same arguments
- Contracts and frontends that call yours keep working. For the frontend side, see integration testing web3 smart contracts and frontend
6. Test the new features
For anything added in the upgrade:
- Write unit tests
- Test how it interacts with the existing functions
- Fuzz the new inputs
7. Verify gas costs
Upgrades can raise gas costs by accident. Check that existing functions didn’t get noticeably more expensive and that the new ones are reasonable. In ethers v6 receipt.gasUsed is a bigint:
it('does not make deposits more expensive', async () => {
// vaultV2 and user come from the upgrade setup in step 4
const tx = await vaultV2.connect(user).deposit({
value: ethers.parseEther('1'),
});
const receipt = await tx.wait();
expect(receipt.gasUsed).to.be.lessThan(80_000n); // measure V1 first, then add headroom
});
I wrote more about ceilings and measuring in gas optimization through testing.
8. Audit, rehearse and deploy
Internal tests don’t replace an external review. Run static analysis with Slither, and consider a manual audit by a security firm for anything that holds funds.
Then rehearse the whole procedure on a fork of mainnet, with the real proxy and real state:
- Time each step
- Write down who runs what, and which multisig or timelock owns the proxy admin
- Prepare a rollback plan and check that it works. With most proxy patterns, rolling back means upgrading again to the old implementation, so keep that implementation verified and ready
A testnet deployment comes next. A bug bounty or community testing at this stage can catch what your team missed.
9. Verify after the upgrade
Once the upgrade is live:
- Run read-only checks against the real contract: balances, totals, owner and implementation address
- Monitor for unusual activity
- Have someone ready to respond to user reports
Testing doesn’t end at deployment. The post-upgrade checks are the only ones that run against the real state.
Pre-upgrade checklist
- Storage layout validated with
validateUpgrade, and new variables appended at the end - Implementation constructor calls
_disableInitializers() - Initializer cannot be called twice, and any
reinitializeris tested - State from V1 survives in V2, checked with a test that upgrades a live-shaped proxy
- Admin or timelock owns the proxy, not a single key
- Gas ceilings still pass
- Old integrations and event signatures still work
- Rollback tested on a fork
Upgrade testing also takes budget, mainly in audit and rehearsal time. See how much web3 testing costs. If you need help setting up an upgrade testing pipeline, see how I work with teams.