Why upgradeable contracts need specialized tests

A normal Solidity contract has one deployment lifecycle. An upgradeable system has at least two:

  1. Proxy deployment: the user-facing address.
  2. 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:

GoalWhat to verifyWhy it matters
Storage compatibilityExisting variables keep their slot positionsPrevents corrupted state after upgrade
Gap disciplineReserved storage space remains availableAllows safe future additions
Initialization safetyInitializers run once and only oncePrevents takeover or state reset
Upgrade correctnessNew logic can be activated without breaking old stateEnsures 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

  1. Compile VaultV1 and save its storage layout.
  2. Compile VaultV2.
  3. Assert that existing variables have not moved.
  4. 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 once
  • initialize() cannot be called again after deployment
  • initializeV2() 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) sets owner
  • a second call reverts
  • calling initializeV2(feeBps) before initialize() 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

  1. Deploy proxy pointing to VaultV1.
  2. Call initialize(deployer).
  3. Send ETH through deposit().
  4. Upgrade proxy to VaultV2.
  5. Verify:
  • owner is unchanged
  • totalDeposits is unchanged
  • feeBps is 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 typeExample assertionTypical bug caught
Layout comparisonowner remains at slot 0Variable insertion or inheritance drift
Gap validationGap shrinks only when new state is addedReserved space misuse
One-time initializationSecond initialize() revertsReinitialization vulnerability
Upgrade persistencetotalDeposits unchanged after upgradeStorage corruption
Versioned initinitializeV2() runs onceReplay 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:

  1. Write the first version with a gap
  • reserve enough storage for near-term changes
  • keep initialization explicit
  1. Snapshot the storage layout
  • store compiler output in version control or CI artifacts
  1. Write an upgrade test before changing code
  • deploy V1, mutate state, upgrade, and assert persistence
  1. Add versioned initialization tests
  • verify one-time execution and correct ordering
  1. 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:

  1. Never trust a new implementation until its storage layout is compared against the old one.
  2. Treat initializers as security-sensitive code paths.
  3. 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.

Learn more with useful resources