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 once
  • VaultV2 must not disturb owner or balance
  • 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

  1. Initializer succeeds once
  • Call initialize() on a fresh proxy.
  • Confirm the expected state is set.
  1. Initializer cannot be called twice
  • Call initialize() again.
  • Expect a revert.
  1. Parent initializers are invoked
  • If using inheritance, ensure each base contract’s initialization logic runs.
  1. 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:

  • owner equals the address passed to initialize()
  • a second call reverts with the expected message or custom error
  • balance remains zero until deposits occur
  • withdrawalFeeBps is unset until initializeV2() 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:

  • owner in slot 0
  • balance in slot 1
  • initialized in 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:

  1. initializing the proxy
  2. depositing ETH
  3. upgrading to VaultV2
  4. reading owner and balance
  5. confirming both values are unchanged

A practical test matrix

The following table summarizes the most useful upgrade tests and what they protect against.

TestPurposeFailure it catches
Initialize onceEnsures setup is performed exactly onceDouble initialization, unguarded setup
Reinitialize blockedPrevents state reset attacksRepeated initializer calls
State preserved after upgradeConfirms proxy storage remains validStorage collision, layout drift
New variable default valueVerifies added state starts cleanlyUnexpected garbage values
Old function behavior unchangedProtects backward compatibilityLogic regressions in upgraded code
Parent initializer executedEnsures inheritance is fully initializedMissing 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

  1. Deploy the first implementation.
  2. Deploy the proxy pointing to that implementation.
  3. Call the initializer through the proxy.
  4. Exercise the contract normally.
  5. Deploy the new implementation.
  6. Upgrade the proxy to the new implementation.
  7. 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.

Learn more with useful resources