
Testing Solidity Upgradeable Contracts with Storage Gaps and Initialization Safety
Why upgradeable contracts need specialized tests
A normal Solidity contract is deployed once and never changes. An upgradeable contract, by contrast, usually lives behind a proxy. The proxy stores the state, while the implementation contract contains the logic. When you upgrade, the proxy points to a new implementation, but the storage layout must remain compatible.
That means your tests need to validate more than business logic. They should answer questions such as:
- Does the initializer run exactly once?
- Does the new implementation preserve existing state?
- Are newly added variables placed safely?
- Did a parent contract change its storage layout in a way that breaks descendants?
- Does the proxy still behave correctly after multiple upgrades?
A contract can pass all functional tests and still be unsafe to upgrade. The failure may only appear after deployment, when state is already live and expensive to recover.
The two most important upgrade risks
1. Initialization mistakes
Upgradeable contracts cannot rely on constructors in the usual way, because the proxy does not execute the implementation constructor during normal use. Instead, contracts use initializer functions such as initialize() or initializeV2().
Common mistakes include:
- forgetting to call the initializer
- allowing the initializer to be called more than once
- failing to initialize parent contracts
- using the wrong access control on reinitializers
2. Storage layout incompatibility
Solidity assigns storage slots based on variable order and inheritance structure. If you insert a new variable in the middle of an existing layout, previously stored values may be read as the wrong type or overwritten entirely.
A safe upgrade usually follows one of these patterns:
- append new variables at the end
- reserve unused space with storage gaps
- keep inheritance order stable
- avoid changing variable types in place
A minimal upgradeable contract example
The following example uses a simple proxy-friendly pattern with an initializer and a storage gap.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VaultV1 {
address public owner;
uint256 public balance;
bool private initialized;
uint256[50] private __gap;
function initialize(address _owner) external {
require(!initialized, "already initialized");
require(_owner != address(0), "zero owner");
initialized = true;
owner = _owner;
}
function deposit() external payable {
balance += msg.value;
}
}
contract VaultV2 is VaultV1 {
uint256 public withdrawalFeeBps;
function initializeV2(uint256 _feeBps) external {
require(withdrawalFeeBps == 0, "already upgraded");
require(_feeBps <= 500, "fee too high");
withdrawalFeeBps = _feeBps;
}
}This example is intentionally simple, but it contains the core testing concerns:
initialize()must only run onceVaultV2must not disturbownerorbalance- the new variable should occupy safe storage space
- the upgrade path should not corrupt existing state
What to test before and after an upgrade
A good test suite should cover both the initial deployment and the post-upgrade state. The goal is to simulate the lifecycle of a real proxy.
Pre-upgrade tests
Before upgrading, verify that:
- the proxy can be initialized exactly once
- state variables are written to the proxy, not the implementation
- access control works after initialization
- the contract behaves correctly with live state
Post-upgrade tests
After upgrading, verify that:
- old values remain intact
- new variables start with expected defaults
- new functions work as intended
- old functions still behave the same
- the contract cannot be reinitialized through a back door
Testing initialization safety
Initialization tests are often the easiest way to catch upgrade bugs early. The key idea is to treat the initializer like a constructor that must be guarded against repeated use.
Test cases to include
- Initializer succeeds once
- Call
initialize()on a fresh proxy. - Confirm the expected state is set.
- Initializer cannot be called twice
- Call
initialize()again. - Expect a revert.
- Parent initializers are invoked
- If using inheritance, ensure each base contract’s initialization logic runs.
- Reinitializer is gated
- If using versioned reinitializers, confirm each version can only run once.
Example test logic
The exact framework may vary, but the assertions should be framework-agnostic:
ownerequals the address passed toinitialize()- a second call reverts with the expected message or custom error
balanceremains zero until deposits occurwithdrawalFeeBpsis unset untilinitializeV2()is called
Testing storage gaps and layout compatibility
Storage gaps are reserved arrays used to preserve room for future variables. They do not “fix” bad upgrades by themselves, but they make safe extension easier.
A typical gap looks like this:
uint256[50] private __gap;This reserves 50 storage slots that future versions can consume by adding new variables in derived contracts or by carefully extending the layout.
What to verify in tests
You generally cannot inspect raw storage layout with ordinary runtime assertions alone, but you can still test the effects of layout compatibility:
- existing values survive the upgrade
- new variables do not overwrite old ones
- inherited state remains readable
- storage-dependent behavior stays consistent
A practical strategy is to seed the contract with known values, upgrade the implementation, and then read those values back.
Example upgrade scenario
Suppose VaultV1 stores:
ownerin slot 0balancein slot 1initializedin slot 2
If VaultV2 inserts a new variable before balance, the old balance value may be interpreted as something else. Your test should detect this by:
- initializing the proxy
- depositing ETH
- upgrading to
VaultV2 - reading
ownerandbalance - confirming both values are unchanged
A practical test matrix
The following table summarizes the most useful upgrade tests and what they protect against.
| Test | Purpose | Failure it catches |
|---|---|---|
| Initialize once | Ensures setup is performed exactly once | Double initialization, unguarded setup |
| Reinitialize blocked | Prevents state reset attacks | Repeated initializer calls |
| State preserved after upgrade | Confirms proxy storage remains valid | Storage collision, layout drift |
| New variable default value | Verifies added state starts cleanly | Unexpected garbage values |
| Old function behavior unchanged | Protects backward compatibility | Logic regressions in upgraded code |
| Parent initializer executed | Ensures inheritance is fully initialized | Missing base state setup |
Testing with a proxy in practice
When testing upgradeable contracts, you should deploy and interact with the proxy, not the implementation contract directly. The implementation contract is only the logic container; the proxy is where state lives.
Recommended workflow
- Deploy the first implementation.
- Deploy the proxy pointing to that implementation.
- Call the initializer through the proxy.
- Exercise the contract normally.
- Deploy the new implementation.
- Upgrade the proxy to the new implementation.
- Re-run state and behavior checks.
This workflow mirrors production and ensures your tests validate the real storage path.
Important detail: test the proxy address
A common mistake is to call functions on the implementation address during tests. That can produce misleading results because:
- the implementation may have empty storage
- the initializer may appear to work but not affect the proxy
- state reads may return default values instead of live values
Always make sure your assertions are against the proxy instance.
Best practices for initializer design
A safe initializer is as important as a safe constructor. Follow these rules:
Use explicit initialization guards
Guard initialization with a boolean flag or a well-audited initializer modifier. The guard should be stored in proxy state, not in transient memory.
Keep initialization idempotent only when intentional
Most initializers should not be idempotent. If they can be called multiple times, that behavior must be deliberate and carefully tested.
Separate versioned setup
If a later upgrade needs new setup logic, use a dedicated reinitializer such as initializeV2(). Test that it cannot be called before the upgrade and cannot be repeated afterward.
Initialize inheritance in order
If your contract inherits from multiple base contracts, ensure each base initializer is called exactly once and in a consistent order. Missing one base initializer can leave critical variables unset.
Common mistakes and how tests expose them
Mistake: adding a variable in the wrong place
If you insert a new variable before existing ones, the layout changes. A good test catches this by upgrading after state has already been written and verifying that the old values still decode correctly.
Mistake: forgetting to reserve storage space
Without a storage gap, future upgrades may have no safe room to extend the layout. Tests won’t always fail immediately, but they should encourage a design that supports future versions.
Mistake: using constructors for initialization
Constructors run on the implementation contract, not the proxy. If your tests only deploy the implementation directly, this bug may remain hidden until production.
Mistake: reinitialization through a new code path
A later upgrade might accidentally expose a function that resets state. Tests should attempt to call all setup-like functions more than once and confirm the contract rejects repeated use.
A robust upgrade test checklist
Before merging an upgradeable contract change, verify the following:
- The proxy is used in all stateful tests.
- The initializer can only be called once.
- Parent initializers are executed.
- Existing storage values survive the upgrade.
- New variables do not overwrite old ones.
- Reinitializer functions are version-gated.
- Old public and external functions still behave correctly.
- The upgrade path is tested with non-empty state, not just a fresh deployment.
When to add storage-layout tests to CI
Storage compatibility checks should run whenever you:
- add or reorder state variables
- change inheritance structure
- introduce a new initializer or reinitializer
- refactor base contracts
- upgrade a deployed proxy in staging or production
These tests are especially valuable in continuous integration because layout bugs are easy to introduce during seemingly harmless refactors.
Conclusion
Testing upgradeable Solidity contracts requires a different mindset from testing ordinary contracts. The most important goal is not only to prove that the current version works, but also to prove that future versions can replace it safely.
By focusing on initialization safety, proxy-based state access, and storage compatibility, you can catch upgrade failures before they become irreversible on-chain problems. A disciplined test suite turns upgradeability from a risk into a maintainable engineering practice.
