Test fixtures are a great way to save time in a smart contract test suite.

Instead of recreating the entire chain state on each test, you can essentially snapshot your chain position, so every time you run a new test case, it starts at the snapshotted state.

One problem, though. As DeFi gets more complex, we're going to begin calling more and more test fixtures in our contract tests. I'm working on a staking library right now, and that relies on several layers of fixtures.

I spent some time this week trying to figure out how the fixtures do work. Turns out, it's actually very simple:

import {providers, Wallet} from 'ethers';
import {MockProvider} from './MockProvider';

export type Fixture<T> = (wallets: Wallet[], provider: MockProvider) => Promise<T>;
interface Snapshot<T> {
  fixture: Fixture<T>;
  data: T;
  id: string;
  provider: providers.Web3Provider;
  wallets: Wallet[];
}

export const loadFixture = createFixtureLoader();

export function createFixtureLoader(overrideWallets?: Wallet[], overrideProvider?: MockProvider) {
  const snapshots: Snapshot<any>[] = [];

  return async function load<T>(fixture: Fixture<T>): Promise<T> {
    const snapshot = snapshots.find((snapshot) => snapshot.fixture === fixture);
    if (snapshot) {
      await snapshot.provider.send('evm_revert', [snapshot.id]);
      snapshot.id = await snapshot.provider.send('evm_snapshot', []);
      return snapshot.data;
    } else {
      const provider = overrideProvider ?? new MockProvider();
      const wallets = overrideWallets ?? provider.getWallets();

      const data = await fixture(wallets, provider);
      const id = await provider.send('evm_snapshot', []);

      snapshots.push({fixture, data, id, provider, wallets});
      return data;
    }
  };
}

https://github.com/EthWorks/Waffle/blob/master/waffle-provider/src/fixtures.ts#L21

Ok, so what's going on here? createFixtureLoader is a factory that returns a loadFixture function, bound to a set of wallets and a provider.

The real magic is with the evm_revert and evm_snapshot calls.

So if you want to nest fixtures, you have to call them a specific way:

  1. Outermost fixture is loaded with loadFixture(outermostFixture)
  2. Inner fixtures are then loaded the way that they get called in loadFixture: fixture(wallets, provider).

That way, it only snapshots once.

Thanks to Nick and Emily for helping with this.