Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/snaps-vitest/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add a Vitest environment that starts the built-in Snap server and exposes
`installSnap()` ([#4175](https://github.com/MetaMask/snaps/pull/4175)).
- Add the Snaps matchers (`toRespondWith`, `toRespondWithError`,
`toSendNotification`, `toRender`, `toTrackError`, `toTrackEvent`, and
`toTrace`) ([#4175](https://github.com/MetaMask/snaps/pull/4175)).
- Add the `installSnap`, `getMockAccount`, `getStateFromAccount`, and
`getStateFromAsset` helpers ([#4175](https://github.com/MetaMask/snaps/pull/4175)).
- Add the `snapsVitestPreset` and `getSnapsVitestConfig` configuration helpers ([#4175](https://github.com/MetaMask/snaps/pull/4175)).

[Unreleased]: https://github.com/MetaMask/snaps/
97 changes: 90 additions & 7 deletions packages/snaps-vitest/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# @metamask/snaps-vitest

A Vitest environment and matchers for end-to-end testing MetaMask Snaps.

This package is a Vitest port of [`@metamask/snaps-jest`](../snaps-jest), and
provides the same end-to-end testing API:
A Vitest environment and matchers for end-to-end testing MetaMask Snaps. This
package is a Vitest port of [`@metamask/snaps-jest`](../snaps-jest), and provides
the same end-to-end testing API:

- A Vitest environment that starts the built-in Snap server and exposes
`installSnap()`.
Expand All @@ -14,22 +13,45 @@ provides the same end-to-end testing API:
`getStateFromAsset` helpers.
- The Snap test types re-exported from `@metamask/snaps-simulation`.

## Setup
The public API is intentionally identical to `snaps-jest`, so existing test
bodies need only test-runner changes (`jest.*` to `vi.*`).

## Requirements

- Node.js `^20` or `>=22`
- Vitest `^3.1.1`

Add the preset to your `vitest.config.ts`:
## Usage

Add the preset to `vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';
import { getSnapsVitestConfig } from '@metamask/snaps-vitest';

export default defineConfig(
getSnapsVitestConfig({
// Optional: makes `describe`, `it`, `expect`, and `vi` global.
globals: true,

// Same options as Jest's `testEnvironmentOptions`.
// `server.root` defaults to `process.cwd()`.
environmentOptions: {
server: {
root: process.cwd(),
},
},
}),
);
```

Write tests as you would with `@metamask/snaps-jest`:
`getSnapsVitestConfig` also configures the Snaps JSX runtime
(`@metamask/snaps-sdk`, production mode) so JSX in tests, such as arguments to
`toRender`, behaves like it does under `ts-jest`. If you prefer to set up Vitest
manually, spread the lower-level `snapsVitestPreset` into `test` and set the
same `esbuild.jsx` options yourself.

Write tests exactly as with `snaps-jest`:

```ts
import { installSnap } from '@metamask/snaps-vitest';
Expand All @@ -47,3 +69,64 @@ describe('My Snap', () => {
});
});
```

### Environment options

| Option | Default | Description |
| ---------------- | --------------- | -------------------------------------------------------------------- |
| `server.enabled` | `true` | Start the built-in HTTP server. |
| `server.port` | `0` | Port for the server. `0` picks a random available port. |
| `server.root` | `process.cwd()` | Directory containing `snap.manifest.json` and the built Snap bundle. |

These are passed through `test.environmentOptions`, not
`testEnvironmentOptions`.

## Migrating tests from `@metamask/snaps-jest`

1. Replace the Jest preset with `snapsVitestPreset` and
`test.environmentOptions`.
2. Replace `@jest/globals` imports with `vitest`.
3. Replace `jest.fn`, `jest.spyOn`, `jest.mock`, and `jest.requireActual` with
their `vi.*` equivalents.
4. `vi.mock` is hoisted like `jest.mock`; use `vi.hoisted()` for variables
referenced inside a mock factory.
5. Test bodies and Snap assertions do not need to change.

The matcher TypeScript declarations extend Vitest's assertion interface.
Importing anything from `@metamask/snaps-vitest` augments `expect`.

## Known differences from Jest

- When a `beforeAll` hook fails, Jest marks every test in the suite as failed,
while Vitest skips the remaining tests. This is a runner difference, not a
Snap behavior difference. The executing tests and assertions behave
identically.
- Parameterised test names generated by `it.each` with object arguments are
formatted differently by each runner.
- Snapshot formatting may differ between Jest and Vitest serializers.
- Integration tests that share a chain can be order-sensitive. Use Vitest's
`sequence.sequencer` or `fileParallelism: false` to pin the file order.

## Development

```sh
yarn workspace @metamask/snaps-vitest run lint
yarn workspace @metamask/snaps-vitest run test
yarn workspace @metamask/snaps-vitest run build
```

## Layout

```text
src/
environment.ts Vitest environment (server + installSnap)
setup.ts Registers the matchers
matchers.ts Snaps matchers
helpers.ts installSnap and mock account helpers
options.ts Environment option validation
global.ts Vitest matcher type augmentation
internals/ Server, logger, account helpers
test-utils/ Test-only helpers and the fixture Snap
test/
e2e.test.ts End-to-end smoke test using the fixture Snap
```
26 changes: 16 additions & 10 deletions packages/snaps-vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,6 @@
"default": "./dist/environment.cjs"
}
},
"./setup": {
"import": {
"types": "./dist/setup.d.mts",
"default": "./dist/setup.mjs"
},
"require": {
"types": "./dist/setup.d.cts",
"default": "./dist/setup.cjs"
}
},
"./config": {
"import": {
"types": "./dist/config.d.mts",
Expand Down Expand Up @@ -83,13 +73,29 @@
"test:verbose": "vitest run --reporter=verbose",
"test:watch": "vitest"
},
"dependencies": {
"@metamask/snaps-controllers": "workspace:^",
"@metamask/snaps-sdk": "workspace:^",
"@metamask/snaps-simulation": "workspace:^",
"@metamask/snaps-utils": "workspace:^",
"@metamask/superstruct": "^3.4.1",
"@metamask/utils": "^12.0.0",
"express": "^5.1.0",
"jest-matcher-utils": "^29.5.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^7.0.1",
"@ts-bridge/cli": "^0.6.1",
"@types/express": "^5.0.1",
"@types/node": "18.14.2",
"depcheck": "^1.4.7",
"eslint": "^9.11.0",
"prettier": "^3.3.3",
"typescript": "~5.8.3",
"vite-tsconfig-paths": "^4.0.5",
"vitest": "^3.1.1"
},
"peerDependencies": {
"vitest": "^3.1.1"
},
"engines": {
Expand Down
79 changes: 79 additions & 0 deletions packages/snaps-vitest/src/environment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import { describe, expect, it, vi } from 'vitest';

import Environment, { SnapsEnvironment } from './environment';

const ROOT = resolve(
fileURLToPath(new URL('.', import.meta.url)),
'./test-utils/snap',
);

const ENVIRONMENT_OPTIONS = {
server: {
root: ROOT,
},
};

describe('SnapsEnvironment', () => {
it('is exported as a valid Vitest environment', () => {
expect(Environment).toMatchObject({
name: 'snaps-vitest',
transformMode: 'ssr',
});

expect(Environment.setup).toBeInstanceOf(Function);
});

it('starts a server if enabled', async () => {
const environment = new SnapsEnvironment(ENVIRONMENT_OPTIONS);

await environment.setup({});
expect(environment.snapId).toMatch(/^local:http:\/\/localhost:\d+$/u);

await environment.teardown();
});

it('throws when trying to access the snap ID when the server is disabled', async () => {
const environment = new SnapsEnvironment({
...ENVIRONMENT_OPTIONS,
server: {
enabled: false,
},
});

await environment.setup({});
expect(() => environment.snapId).toThrow(
'You must specify a snap ID, because the built-in server is not running.',
);

await environment.teardown();
});

describe('installSnap', () => {
it('installs a Snap and keeps track of it, terminating any previously installed Snaps', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);

const environment = new SnapsEnvironment(ENVIRONMENT_OPTIONS);

await environment.setup({});

const instance = await environment.installSnap();
expect(instance?.executionService).toBeDefined();

const terminateSpy = vi.spyOn(
instance?.executionService,
'terminateAllSnaps',
);

const secondInstance = await environment.installSnap();
expect(terminateSpy).toHaveBeenCalledTimes(1);
expect(secondInstance?.executionService).toBeDefined();
expect(secondInstance?.executionService).not.toBe(
instance?.executionService,
);

await environment.teardown();
});
});
});
Loading
Loading