Jest is the testing framework for unit tests at MetaMask. Jest includes many built-in features that make it easier to write tests. Useful features include:
- Module mocks
- Timer mocks
- Snapshots
- Automatic parallelization of tests without restricting usage of exclusive tests
- A consistent emphasis on great developer experience
Place test files next to the code they test. This makes test files easier to find.
🚫
src/
permission-controller.ts
test/
permission-controller.ts
✅
src/
permission-controller.ts
permission-controller.test.ts
Wrap tests for the same function or method in a describe block. This provides three benefits:
- Tests are easier to find in large test files
- You can run only these tests using
.only - The test subject (the "it") is clear and focused
🚫
describe('KeyringController', () => {
it('adds a new account to the given keyring', () => {
// ...
});
it('removes the account identified by the given address from its associated keyring', () => {
// ...
});
});✅
describe('KeyringController', () => {
describe('addAccount', () => {
it('adds a new account to the given keyring', () => {
// ...
});
});
describe('removeAccount', () => {
it('removes the account identified by the given address from its associated keyring', () => {
// ...
});
});
});When multiple tests verify the same conditional behavior, group them in a describe block. This way, you only need to specify the condition once. Start each test's name with if ... or when ... so it forms a complete sentence when combined with the describe block.
1️⃣
it('delegates to the tokens controller when adding a token', () => {
// ...
});
it('adds the new token to the state when adding a token', () => {
// ...
});
it('returns true when adding a token', () => {
// ...
});2️⃣
describe('when adding a token', () => {
it('delegates to the tokens controller', () => {
// ...
});
it('adds the new token to the state', () => {
// ...
});
it('returns true', () => {
// ...
});
});Each test must focus on a single aspect of the behavior, and its description must also describe that behavior clearly:
- Start the description with an active verb in present tense (e.g., "returns", "displays", "prevents").
- State the expected outcome or behavior directly.
- Add contextual conditions only when necessary for clarity (e.g., "when session expires").
- Keep descriptions concise but complete.
- Avoid filler words ("should", "correctly", "successfully", "gracefully", "properly").
- Avoid vague descriptions ("test", "edge case", "works", "handles").
- Avoid implementation details ("calls redirectTo", "throws InvalidPayloadError").
- Don't use the name of the function as the test description.
🚫 Using "should" unnecessarily
it('should successfully add token when address is valid and decimals are set and symbol exists', () => {
// ...
});✅ Describe the behavior directly
it('stores valid token in state', () => {
// ...
});🚫 Listing implementation details and parameters
it('should fail and show error message when invalid address is provided', () => {
// ...
});✅ Focus on what is being tested
it('displays invalid address error', () => {
// ...
});🚫 Stating obvious successful outcomes
it('works correctly when processing the transaction', () => {
// ...
});✅ Be specific about the behavior
it('processes transaction', () => {
// ...
});🚫 Describing implementation instead of behavior
it('calls redirectTo("/login") when session expires', () => {
// ...
});✅ Describe the expected outcome
it('redirects to login when session expires', () => {
// ...
});🚫 Using vague error language
it('throws an error when balance is insufficient', () => {
// ...
});✅ Be precise about the expected behavior
it('prevents sending with insufficient balance', () => {
// ...
});Or, when the specific error type is the key behavior:
it('throws InvalidPayloadError on malformed request', () => {
// ...
});🚫 Missing or unclear description
it('test', () => {
// ...
});
it('edge case', () => {
// ...
});✅ Clear, descriptive names
it('returns empty array when input is empty', () => {
// ...
});
it('accepts transaction up to maximum amount limit', () => {
// ...
});- "Tests as Specification" and "Tests as Documentation" in xUnit Patterns
Tests are easier to understand and maintain when they cover only one aspect of behavior.
If you use "and" in a test description, the test is probably too large. Split it into separate tests.
🚫
it('starts the block tracker and returns the block number', () => {
// ...
});✅
it('starts the block tracker', () => {
// ...
});
it('returns the block number', () => {
// ...
});Private code is not intended for consumers of an interface, so don't test it directly. Private code includes:
- Functions or classes not exported from a module
- Methods that start with
#(ECMAScript private fields) - Methods that start with
_(older convention before private fields) - Methods with the
privatekeyword in TypeScript - Functions or methods tagged with
@privatein TSDoc
Instead, test the public methods that call the private code, writing tests as if the private code were part of the public method.
The following example defines two private methods:
// block-tracker.ts
import { request } from '...';
export class BlockTracker extends EventEmitter {
isRunning = false;
currentBlock: Block | null = null;
subscriptionId: number | null;
async stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
this.#startClearCurrentBlockTimer();
await this.#end();
}
#startClearCurrentBlockTimer() {
setTimeout(() => {
this.currentBlock = null;
}, 1000);
}
#end() {
await request('eth_unsubscribe', this.subscriptionId);
}
}Since consumers (and tests) can only see the public interface, the example above behaves as if the private methods were inlined:
// block-tracker.ts
import { request } from '...';
export class BlockTracker extends EventEmitter {
isRunning = false;
currentBlock: Block | null = null;
subscriptionId: number | null;
async stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
setTimeout(() => {
this.currentBlock = null;
}, 1000);
await request('eth_unsubscribe', this.subscriptionId);
}
}A test suite for the class might look like this. Testing the public stop method verifies all the behaviors that result from calling it, including the effects of the private methods:
describe('BlockTracker', () => {
describe('stop', () => {
it('does not reset the current block if the block tracker is stopped', () => {
// ...
});
it('does not request to unsubscribe if the block tracker is stopped', () => {
// ...
});
it('resets the current block if the block tracker is running', () => {
// ...
});
it('requests to unsubscribe if the block tracker is running', () => {
// ...
});
});
});A test has up to four "phases":
- Setup: Configure the environment to run the code under test
- Exercise: Execute the code under test
- Verify: Confirm that the code behaves as expected
- Teardown: Return the environment to a clean state
Use empty lines to separate these phases visually. This helps readers understand the test flow.
In this example, the empty lines make it hard to see the relationships between parts. Which part executes the code? Which part confirms the behavior? Which part sets up the test?
1️⃣
describe('KeyringController', () => {
describe('submitEncryptionKey', () => {
it('unlocks the keyrings with valid information', async () => {
const keyringController = await initializeKeyringController({
password: 'password',
});
keyringController.cacheEncryptionKey = true;
const returnValue = await keyringController.encryptor.decryptWithKey();
const decryptWithKeyStub = sinon.stub(
keyringController.encryptor,
'decryptWithKey',
);
decryptWithKeyStub.resolves(Promise.resolve(returnValue));
keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA });
await keyringController.setLocked();
await keyringController.submitEncryptionKey(
'mockEncryptionKey',
'mockEncryptionSalt',
);
expect(keyringController.encryptor.decryptWithKey.calledOnce).toBe(true);
expect(keyringController.keyrings).toHaveLength(1);
});
});
});This version is clearer. All setup code is grouped together visually, making the phases easy to identify:
2️⃣
describe('KeyringController', () => {
describe('submitEncryptionKey', () => {
it('unlocks the keyrings with valid information', async () => {
const keyringController = await initializeKeyringController({
password: 'password',
});
keyringController.cacheEncryptionKey = true;
const returnValue = await keyringController.encryptor.decryptWithKey();
const decryptWithKeyStub = sinon.stub(
keyringController.encryptor,
'decryptWithKey',
);
decryptWithKeyStub.resolves(Promise.resolve(returnValue));
keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA });
await keyringController.setLocked();
await keyringController.submitEncryptionKey(
'mockEncryptionKey',
'mockEncryptionSalt',
);
expect(keyringController.encryptor.decryptWithKey.calledOnce).toBe(true);
expect(keyringController.keyrings).toHaveLength(1);
});
});
});- "Arrange, act, assert" is a simplification of the four-phase test.
- In behavior-driven development, this is also called "given, when, then".
A test must always pass. Running a test by itself or with a group of other tests must not change the result.
Tests must run in a clean environment. If a test changes any part of the environment, it must undo those changes before finishing. This prevents the test from affecting other tests.
The next sections suggest ways to keep tests isolated.
Set Jest's resetMocks and restoreMocks configuration options to true. Jest will then reset all mock functions and return them to their original implementations after each test. (MetaMask's module template includes this setting.)
Read more
Mock functions that are visible to multiple tests must be reset properly. Otherwise, their state will affect other tests.
This example has two tests. The second test assumes the spy on getNetworkStatus from the first test is removed, but it is not:
🚫
const optionsMock = {
getNetworkStatus: () => 'available',
};
describe('token-utils', () => {
it('returns null if the network is still loading', () => {
// Oops! This changes the return value of this mock for every other test
jest.spyOn(optionsMock, 'getNetworkStatus').mockReturnValue('loading');
expect(getTokenDetails('0xABC123', optionsMock)).toBeNull();
});
it('returns the details about the given token', () => {
// This will likely not work as `getNetworkStatus` still returns "loading"
expect(getTokenDetails('0xABC123', optionsMock)).toStrictEqual({
standard: 'ERC20',
symbol: 'TEST',
});
});
});You can save the spy to a variable and call mockRestore on it before the test ends:
const optionsMock = {
getNetworkStatus: () => 'available',
};
describe('token-utils', () => {
it('returns null if the network is still loading', () => {
const getNetworkStatusSpy = jest
.spyOn(optionsMock, 'getNetworkStatus')
.mockReturnValue('loading');
expect(getTokenDetails('0xABC123', optionsMock)).toBeNull();
getNetworkStatusSpy.mockRestore();
});
it('returns the details about the given token', () => {
expect(getTokenDetails('0xABC123', optionsMock)).toStrictEqual({
standard: 'ERC20',
symbol: 'TEST',
});
});
});However, the better approach is to set resetMocks and restoreMocks in your Jest configuration. Jest will then handle this automatically.
Create a helper function that wraps the code under test. This ensures changes to global variables are undone after use.
When possible, use dependency injection to pass globals to the code under test. This lets you pass mock implementations in tests.
Read more
Global variables are properties of the global context (usually global). Changes to these variables affect every test in a test file.
If you want to change a global function in a test, mock it using jest.spyOn. This makes the mock easy to reset later.
🚫
describe('NftDetails', () => {
it('opens a tab', () => {
// This change will apply to every other test that's run after this one
global.platform = { openTab: jest.fn() };
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
await waitFor(() => {
expect(global.platform.openTab).toHaveBeenCalledWith({
url: 'https://testnets.opensea.io/assets/goerli/0xABC123/1',
});
});
});
it('renders the View Opensea button after opening a tab', () => {
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
// Oops! `global.platform.openTab` will still be a mock function, so
// if this element is supposed to appear after opening a tab, it won't
expect(queryByTestId('nft-options__view-on-opensea')).toBeInTheDocument();
});
});✅
describe('NftDetails', () => {
it('opens a tab', () => {
// Now, as long as we set set Jest's `restoreMocks` configuration option to
// true, this should work as expected
jest.spyOn(global.platform, 'openTab').mockReturnValue();
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
await waitFor(() => {
expect(global.platform.openTab).toHaveBeenCalledWith({
url: 'https://testnets.opensea.io/assets/goerli/0xABC123/1',
});
});
});
it('renders the View Opensea button after opening a tab', () => {
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
// `global.platform.openTab` should no longer be mocked, so this will appear
expect(queryByTestId('nft-options__view-on-opensea')).toBeInTheDocument();
});
});If you want to change a global property that is not a function, do not assign it directly in a test:
🚫
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
// This change will apply to every other test that's run after this one
global.ethereumProvider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// Oops! This will still hit Sepolia
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});Instead of using hooks, create a function that wraps your test. This function captures the current global value before the test and restores it afterward:
1️⃣
async function withEthereumProvider(
ethereumProvider: Provider,
test: () => void | Promise<void>,
) {
const originalEthereumProvider = global.ethereumProvider;
global.ethereumProvider = ethereumProvider;
await test();
global.ethereumProvider = originalEthereumProvider;
}
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
const sepolia = new HttpProvider('https://sepolia.infura.io/v3/abc123');
withEthereumProvider(sepolia, () => {
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// Now this test will "just work"
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});A better approach is to use dependency injection to remove the need for a global variable. This lets you create a fake value within your test:
2️⃣
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
const provider = createFakeProvider({ network: 'sepolia' });
expect(interpretMethodData(provider, '0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
const provider = createFakeProvider({ network: 'mainnet' });
expect(interpretMethodData(provider, '0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});Use helper functions instead of variables to define data shared between tests.
Read more
Variables declared outside of tests are not reset automatically between tests. Changes to these variables in one test can affect other tests. This breaks test isolation.
Example:
🚫
const NETWORK = {
provider: new HttpProvider('https://mainnet.infura.io/v3/abc123');
};
describe("interpretMethodData", () => {
it("returns the signature for setApprovalForAll on Sepolia", () => {
// This change will apply to every other test that's run after this one
NETWORK.provider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab', NETWORK)).toStrictEqual({
name: 'Set Approval For All'
});
});
it("returns the signature for setApprovalForAll on Mainnet", () => {
// Oops! This will still hit Sepolia
expect(interpretMethodData('0x3fbac0ab', NETWORK)).toStrictEqual({
name: 'Set Approval For All'
});
});
});Using beforeEach might seem like a solution:
🚫
describe("interpretMethodData", () => {
const network;
beforeEach(() => {
// Now this variable is reset before each test
network = {
provider: new HttpProvider('https://mainnet.infura.io/v3/abc123');
};
});
it("returns the signature for setApprovalForAll on Sepolia", () => {
// This change will no longer apply to every other test
network.provider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All'
});
});
it("returns the signature for setApprovalForAll on Mainnet", () => {
// This will use Mainnet, as expected
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All'
});
});
});Instead of using hooks, use a factory function. This function should define default values and let you override them when needed:
✅
function buildNetwork({
provider = HttpProvider('https://mainnet.infura.io/v3/abc123'),
}: Partial<{
provider: HttpProvider;
}> = {}) {
return { provider };
}
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
// Now `network` only lives for the duration of the test, so it cannot be
// shared among other tests
const network = buildNetwork({
provider: new HttpProvider('https://sepolia.infura.io/v3/abc123'),
});
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// This test will use Mainnet by default thanks to how we defined the helper
const network = buildNetwork();
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All',
});
});
});Extract shared setup steps into functions instead of putting them in a beforeEach hook:
🚫
describe('TokenDetectionController', () => {
let getCurrentChainId: jest.mock<Promise<string>, []>;
let preferencesController: PreferencesController;
let tokenDetectionController: TokenDetectionController;
beforeEach(() => {
getCurrentChainId = jest.fn().mockResolvedValue('0x1');
preferencesController = new PreferencesController({
getCurrentChainId,
});
tokenDetectionController = new TokenDetectionController({
preferencesController,
});
});
describe('constructor', () => {
it('sets default state', () => {
expect(tokenDetectionController.state).toStrictEqual({
tokensByChainId: {},
});
});
});
describe('detectTokens', () => {
it('tracks tokens for the currently selected chain', async () => {
const sampleToken = { symbol: 'TOKEN', address: '0x2' };
getCurrentChainId.mockResolvedValue('0x2');
jest
.spyOn(tokenDetectionController, 'fetchTokens')
.mockResolvedValue(['0xAAA', '0xBBB']);
await tokenDetectionController.detectTokens();
expect(
tokenDetectionController.state.tokensByChainId['0x2'],
).toStrictEqual(['0xAAA', '0xBBB']);
});
});
});✅
describe('TokenDetectionController', () => {
describe('constructor', () => {
it('sets default state', () => {
const { controller } = buildTokenDetectionController();
expect(controller.state).toStrictEqual({
tokensByChainId: {},
});
});
});
describe('detectTokens', () => {
it('tracks tokens for the currently selected chain', async () => {
const sampleToken = { symbol: 'TOKEN', address: '0x2' };
const { controller } = buildTokenDetectionController({
getCurrentChainId: () => '0x2',
});
jest
.spyOn(controller, 'fetchTokens')
.mockResolvedValue(['0xAAA', '0xBBB']);
await controller.detectTokens();
expect(controller.state.tokensByChainId['0x2']).toStrictEqual([
'0xAAA',
'0xBBB',
]);
});
});
});
function buildTokenDetectionController({
getCurrentChainId = () => '0x1',
}: {
getCurrentChainId?: () => string;
}) {
const preferencesController = new PreferencesController({
getCurrentChainId,
});
const tokenDetectionController = new TokenDetectionController({
preferencesController,
});
return { controller: tokenDetectionController, preferencesController };
}Read more
Using a beforeEach hook to set up tests with similar needs might seem convenient. However, this approach increases maintenance costs for two reasons:
- It makes tests harder to read. Different tests may need different setup, but
beforeEachassigns equal importance to all setup steps. Readers must read all the setup code to find what matters for each test. - It makes writing new tests difficult. The
beforeEachsetup may not fit new test scenarios. You may need complex refactoring to remove steps that don't apply, or use workarounds that hurt consistency and readability.
Setup helper functions solve these problems in two ways:
- Tests can pass options to the function to specify the setup they need, showing readers what is important for each test.
- Setup code is easier to refactor when needed.
Helper functions work for teardown too, not just setup. Consider the following example, which uses beforeEach to create a controller and afterEach to destroy it:
🚫
describe('TokensController', () => {
let controller: TokensController;
let onNetworkDidChangeListener: (
networkState: NetworkState,
) => void | undefined;
beforeEach(() => {
onNetworkDidChange = jest.fn();
controller = new TokensController({
chainId: '0x1',
onNetworkDidChange: (listener) => {
onNetworkDidChangeListener = listener;
},
});
});
afterEach(() => {
controller.destroy();
});
describe('addToken', () => {
it('registers the given token under the default network, assuming it has not changed yet', () => {
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x1': {
symbol: 'DAI',
},
});
});
it('registers the given token under the current network, even after it changes', () => {
onNetworkDidChangeListener!({ chainId: '0x42' });
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x42': {
symbol: 'DAI',
},
});
});
});
});A more maintainable pattern is a function that wraps the test, automatically creating and destroying the controller before and after it runs:
✅
type WithControllerOptions = Partial<TokensControllerOptions>;
type WithControllerCallback = (setup: {
controller: TokensController;
onNetworkDidChangeListener: (networkState: NetworkState) => void;
}) => void | Promise<void>;
type WithControllerArgs =
| [WithControllerOptions, WithControllerCallback]
| [WithControllerCallback];
async function withController(...args: WithControllerArgs) {
const [
{
chainId = '0x1',
onNetworkDidChange = (listener) => {
onNetworkDidChangeListener = listener;
},
},
fn,
] = args.length === 1 ? [{}, args[0]] : args;
const onNetworkDidChangeListener: (networkState: NetworkState) => void;
const controller = new TokensController({ chainId, onNetworkDidChange });
assert(onNetworkDidChangeListener, 'onNetworkDidChangeListener was not set');
try {
await fn({ controller, onNetworkDidChangeListener });
} finally {
controller.destroy();
}
}
describe('TokensController', () => {
describe('addToken', () => {
it('registers the given token under the default network, assuming it has not changed yet', async () => {
await withController({ chainId: '0x1' }, ({ controller }) => {
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x1': {
symbol: 'DAI',
},
});
});
});
it('registers the given token under the current network, even after it changes', () => {
await withController(({ controller, onNetworkDidChangeListener }) => {
onNetworkDidChangeListener({ chainId: '0x42' });
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x42': {
symbol: 'DAI',
},
});
});
});
});
});Tests often use data that is essential for setup or verification.
Keep this data inside the test instead of spreading it across the file or project. This makes the test easier to understand.
🚫
const sampleMainnetTokenList = [
{
address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
symbol: 'SNX',
decimals: 18,
occurrences: 11,
name: 'Synthetix',
iconUrl: 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png',
aggregators: ['Aave', 'Bancor', 'CMC'],
},
];
const sampleMainnetTokensChainsCache = sampleMainnetTokenList.reduce(
(output, current) => {
output[current.address] = current;
return output;
},
{} as TokenListMap,
);
const sampleSingleChainState = {
tokenList: {
'0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': {
address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
symbol: 'SNX',
decimals: 18,
occurrences: 11,
name: 'Synthetix',
iconUrl: 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png',
aggregators: ['Aave', 'Bancor', 'CMC'],
},
};
};
const sampleTwoChainState = {
tokensChainsCache: {
'0x1': {
timestamp,
data: sampleMainnetTokensChainsCache,
}
},
}
// ... many, many lines later ...
describe('TokensController', () => {
describe('start', () => {
it('loads the token list for the selected chain', async () => {
// The setup phase involves `sampleMainnetTokenList`...
nock(TOKEN_END_POINT_API)
.get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`)
.reply(200, sampleMainnetTokenList);
const controller = new TokenListController({
chainId: ChainId.mainnet,
});
await controller.start();
// ...and the verification phase involves `sampleSingleChainState` and
// `sampleTwoChainState`. But it's not clear what they have to do with
// `sampleMainnetTokenList` without scrolling all the way up and reading
// all the way through the variables.
expect(controller.state.tokenList).toStrictEqual(
sampleSingleChainState.tokenList,
);
expect(
controller.state.tokensChainsCache[ChainId.mainnet].data,
).toStrictEqual(
sampleTwoChainState.tokensChainsCache[ChainId.mainnet].data,
);
});
});
});✅
describe('TokensController', () => {
describe('start', () => {
it('loads the token list for the selected chain', async () => {
// Now all of the data that the test ends up using in the execution and
// verification phases is clearly spelled out. This also gives us an
// opportunity to simplify the test setup.
const chainIdInHex = '0x1';
const chainIdInDecimal = '1';
const tokensByAddress = {
'0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': {
address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
symbol: 'SNX',
decimals: 18,
occurrences: 11,
name: 'Synthetix',
iconUrl: 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png',
aggregators: ['Aave', 'Bancor', 'CMC'],
};
};
nock(TOKEN_END_POINT_API)
.get(`/tokens/${chainIdInDecimal}`)
.reply(200, Object.values(tokensByAddress));
const controller = new TokenListController({
chainId: chainIdInHex,
});
await controller.start();
expect(controller.state.tokenList).toStrictEqual(tokensByAddress);
expect(
controller.state.tokensChainsCache[chainIdInHex].data,
).toStrictEqual(tokensByAddress);
});
});
});Jest includes most of Sinon's features with a simpler API:
- Use
jest.fn()instead ofsinon.stub(). - Use
jest.spyOn(object, method)instead ofsinon.spy(object, method)orsinon.stub(object, method). (Note: The spied method will still be called by default.) - Use
jest.useFakeTimers()instead ofsinon.useFakeTimers(). (Note: Jest's "clock" object had fewer features than Sinon's before Jest v29.5.)
Jest's documentation states: "Manual mocks are defined by writing a module in a __mocks__/ subdirectory immediately". Jest automatically picks up these mocks for all tests. Be very careful when writing manual mocks because they are shared across all tests (including UI integration tests).
Jest snapshots do not test whether a value is valid. They only check for changes since the last snapshot was created.
Do not consider snapshot matching as a full test of a component. Snapshots only verify that the component rendered without errors. The snapshot might show an error screen instead of the actual component.
Name your snapshot test cases clearly:
🚫 Wrong naming
describe('MyComponent', () => {
it('should renders correctly', () => {
// ...
});
});✅ Correct naming
describe('MyComponent', () => {
it('matches rendered snapshot', () => {
// ...
});
});You can use variants of this naming to add context. For example:
describe('MyComponent', () => {
it('matches rendered snapshot when not enabled', () => {
// ...
});
});