
Testing Solidity Upgradeable Contracts with Storage Gaps and Initialization Safety
Why upgradeable contracts need specialized tests
A normal Solidity contract has one deployment lifecycle. An upgradeable system has at least two:
- Proxy deployment: the user-facing address.
- Implementation deployment: the logic contract behind the proxy.
Because the proxy stores state while the implementation supplies code, the most dangerous bugs are not in business logic itself but in how state is preserved between versions. Common failure modes include:
- inserting a new state variable in the wrong place
- shrinking or removing reserved storage gaps
- forgetting to initialize a new variable during upgrade
- allowing an initializer to run twice
- accidentally changing inheritance order and shifting storage slots
These bugs are hard to spot with ordinary unit tests because the contract may still compile and even appear to work in a fresh deployment. The real test is whether a proxy upgraded from version N to version N+1 still reads the same state from the same slots.
The core testing goals
When testing upgradeable contracts, focus on four questions:
| Goal | What to verify | Why it matters |
|---|---|---|
| Storage compatibility | Existing variables keep their slot positions | Prevents corrupted state after upgrade |
| Gap discipline | Reserved storage space remains available | Allows safe future additions |
| Initialization safety | Initializers run once and only once | Prevents takeover or state reset |
| Upgrade correctness | New logic can be activated without breaking old state | Ensures the proxy remains usable |
A good test suite should fail if a developer makes a seemingly harmless change like adding a variable above an inherited base contract or renaming an initializer without preserving its versioning.
A minimal upgradeable pattern to test
The following example uses a proxy-friendly pattern with an initializer and a storage gap. The exact proxy mechanism can vary, but the testing principles remain the same.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VaultV1 {
address public owner;
uint256 public totalDeposits;
bool private initialized;
uint256[49] private __gap;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function initialize(address _owner) external {
require(!initialized, "already initialized");
initialized = true;
owner = _owner;
}
function deposit() external payable {
totalDeposits += msg.value;
}
}
contract VaultV2 is VaultV1 {
uint256 public feeBps;
function initializeV2(uint256 _feeBps) external {
require(feeBps == 0, "already upgraded");
feeBps = _feeBps;
}
function setFeeBps(uint256 _feeBps) external onlyOwner {
feeBps = _feeBps;
}
}This example is intentionally simple. In production, you would typically use a standard upgradeable base library, but the testing concerns are the same: owner, totalDeposits, and initialized must remain stable, while feeBps must fit into reserved space or a compatible extension point.
Testing storage layout stability
The most important upgrade test is a layout compatibility check. You want to ensure that variables in the new implementation occupy the same slots as before, or are added only in reserved space.
What to inspect
At a minimum, compare:
- variable order
- inheritance order
- slot numbers
- offsets for packed values
- size of any storage gap
A practical approach is to use the compiler’s storage layout output and compare it between versions in CI. This catches accidental changes before deployment.
Example test strategy
- Compile
VaultV1and save its storage layout. - Compile
VaultV2. - Assert that existing variables have not moved.
- Assert that the gap still exists and is large enough for future additions.
If your tooling exposes storage layout as JSON, you can compare entries by variable name and slot. The exact mechanism depends on your framework, but the principle is universal: treat storage layout as an API.
What to look for in failures
A failing layout test often means one of these changes occurred:
- a new variable was inserted before existing state
- a base contract was reordered in the inheritance list
- a packed variable changed type size and shifted following fields
- a gap was removed or reduced too aggressively
These are not cosmetic changes. They can silently corrupt user balances, ownership, or configuration.
Verifying storage gaps are preserved
Storage gaps are reserved arrays of unused slots, usually declared like this:
uint256[49] private __gap;Their purpose is to absorb future state variables without changing the positions of inherited storage. A gap is useful only if it is managed carefully.
Best practices for gap tests
- Confirm the gap exists in every upgradeable base contract.
- Confirm the gap size decreases only when new variables are added.
- Confirm the new variables fit entirely within the reserved space.
- Confirm no state variable is added after the gap in a way that changes layout expectations.
A useful assertion pattern
If VaultV1 reserves 49 slots and VaultV2 adds one new uint256, then the gap should shrink to 48 slots in the same storage region. If the gap remains unchanged while a new variable is added elsewhere, that is a red flag: the new variable may have been appended in a way that breaks compatibility.
A test can also assert that the total storage footprint of the contract family remains bounded and intentional. This is especially useful in large inheritance trees where one forgotten base contract can invalidate the whole layout.
Testing initializer behavior
Upgradeable contracts cannot rely on constructors for runtime setup because constructors run on the implementation contract, not the proxy. Instead, they use initializer functions. That makes initialization a critical security boundary.
What to test
initialize()succeeds exactly onceinitialize()cannot be called again after deploymentinitializeV2()can only be called once and only after the base initializer- privileged state is set during initialization and not left unset
- the implementation contract itself cannot be used as a live instance
Why this matters
If an initializer is callable twice, an attacker may reset ownership or reconfigure the contract. If a versioned initializer is missing a guard, the upgrade path can be replayed. If initialization is incomplete, the proxy may be left in a partially configured state.
Example assertions
A robust test suite should check:
- calling
initialize(owner)setsowner - a second call reverts
- calling
initializeV2(feeBps)beforeinitialize()fails or is impossible - a second call to
initializeV2()fails setFeeBps()remains restricted to the owner after upgrade
The exact revert message is less important than the invariant: initialization must be one-time and ordered.
Testing upgrades without losing state
A realistic upgrade test deploys version 1, writes state, upgrades to version 2, and then verifies that the old state is still readable.
Example scenario
- Deploy proxy pointing to
VaultV1. - Call
initialize(deployer). - Send ETH through
deposit(). - Upgrade proxy to
VaultV2. - Verify:
owneris unchangedtotalDepositsis unchangedfeeBpsis initialized to its expected default- new functions work correctly
Why this test is powerful
This catches the most expensive category of upgrade bugs: state migration failures that only appear after a live upgrade. A contract may pass all unit tests in isolation but still fail when the proxy reads old storage with a new layout.
Practical test matrix
The following matrix helps organize your upgrade test coverage:
| Test type | Example assertion | Typical bug caught |
|---|---|---|
| Layout comparison | owner remains at slot 0 | Variable insertion or inheritance drift |
| Gap validation | Gap shrinks only when new state is added | Reserved space misuse |
| One-time initialization | Second initialize() reverts | Reinitialization vulnerability |
| Upgrade persistence | totalDeposits unchanged after upgrade | Storage corruption |
| Versioned init | initializeV2() runs once | Replay of upgrade setup |
Use this matrix as a checklist whenever a new implementation is proposed.
Common mistakes and how to avoid them
1. Adding variables in the wrong place
Never insert new state above existing variables in an upgradeable base contract. Even a harmless-looking uint256 public feeBps; can shift every following slot.
Fix: append new variables only in reserved space or in a new contract that is designed for extension.
2. Changing inheritance order
Inheritance order affects storage layout. Reordering base contracts can move inherited variables even if the source code looks logically equivalent.
Fix: treat inheritance order as immutable once deployed.
3. Forgetting versioned initializers
If you add new state in V2, the original initializer is not enough. You need a separate versioned initializer for the new fields.
Fix: use initializeV2() or a similar versioned pattern and test that it cannot be replayed.
4. Leaving the implementation contract unprotected
The implementation contract should usually not be initialized directly in a way that gives it a meaningful live state.
Fix: ensure the implementation is either locked or harmless when deployed standalone.
Recommended testing workflow
A reliable workflow for upgradeable contracts looks like this:
- Write the first version with a gap
- reserve enough storage for near-term changes
- keep initialization explicit
- Snapshot the storage layout
- store compiler output in version control or CI artifacts
- Write an upgrade test before changing code
- deploy V1, mutate state, upgrade, and assert persistence
- Add versioned initialization tests
- verify one-time execution and correct ordering
- Compare layouts in CI
- fail the build if any existing slot moves
This workflow is especially valuable in teams where multiple developers may touch the same inheritance tree over time.
When storage gaps are not enough
A storage gap is a practical tool, but it is not a substitute for disciplined design. If a future upgrade requires a major structural change, you may need:
- a migration contract
- explicit state copying
- a new proxy with a controlled cutover
- a fresh storage namespace strategy
In other words, gaps help with incremental evolution, not arbitrary redesign. Your tests should reflect that boundary. If a change cannot be expressed safely within the existing layout, the test suite should make that failure obvious.
Summary
Testing upgradeable Solidity contracts is about protecting state continuity. The most valuable tests do not just confirm that functions return expected values; they confirm that upgrades preserve storage, initialization remains one-time, and reserved gaps are used intentionally.
If you only remember three rules, make them these:
- Never trust a new implementation until its storage layout is compared against the old one.
- Treat initializers as security-sensitive code paths.
- Test the full upgrade flow, not just isolated contract versions.
With those checks in place, you can upgrade confidently without turning your proxy into a state-corruption risk.
