Testing
The three-suite testing layout for devstack apps — unit, e2e, and browser.
devstack splits tests into three suites by directory. Pick the level that matches what you're testing:
- Unit (
tests/unit/) — test your generated bindings directly: BCS encode/decode, Move-call builders, pure logic. No stack, no Docker, no on-chain ids. Fast. See Vitest. - E2e (
tests/e2e/) — test real transactions against a live local stack, in node. The suite boots a stack, publishes your packages, runs against the real ids, and tears down. See Vitest. - Browser (
tests/browser/) — test the running app in a real browser, signing through the dev wallet. Asserts what the app actually renders after a signed on-chain action. See Playwright.
The split is purely by directory. The vitest configs scope themselves to tests/unit/ or
tests/e2e/ and exclude tests/browser/; Playwright runs tests/browser/ and vitest never touches
it. Each full-stack suite boots its own stack, so the suites can run in parallel with each other and
with a pnpm dev session.
| Suite | Directory | What you test | Helper subpath | Stack |
|---|---|---|---|---|
| Unit | tests/unit/ | generated bindings | @mysten-incubation/devstack/vite (alias only) | none |
| E2e | tests/e2e/ | real transactions on a stack | @mysten-incubation/devstack/vitest | test |
| Browser | tests/browser/ | the running app + dev wallet | @mysten-incubation/devstack/playwright | e2e |
The example apps ship the unit and browser suites; the vitest e2e suite is the same mechanism wired
to tests/e2e/ (autoBoot: true), useful for full-stack coverage without a browser.
Run matrix
{
"scripts": {
"test": "vitest run",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:browser": "DEVSTACK_STACK=e2e DEVSTACK_AUTO_APPROVE=1 DEVSTACK_E2E=1 playwright test"
}
}pnpm testruns the unit suite (tests/unit/). No stack, no Docker — plain Vitest over your generated bindings.pnpm test:e2eboots a dedicatedteststack (codegen included), runs thetests/e2e/suite against it in node, and tears it down. Self-contained, and isolated from a runningpnpm devstack.testis the recommended stack name for non-browser tests so they don't contend with a developer'spnpm devstack.pnpm test:browserboots thee2estack and drives the app in a browser through the dev wallet (tests/browser/).
Two distinct env vars gate the browser suite, and they do different things:
DEVSTACK_AUTO_APPROVE=1makes the dev wallet sign without showing its approval UI.DEVSTACK_E2E=1is a dedicated signal — distinct from auto-approve — that the Vite plugin injects as the__DEVSTACK_E2E__global. It gates dApp Kit'sautoConnecton only under e2e, so a normalpnpm devserve still loads disconnected. Browser specs that expect the app to auto-connect need it set.
Each suite resolves @generated to its own stack's deployment, so the same import reads whichever
stack is live; DEVSTACK_STACK selects it. To run a suite against an already-deployed stack instead
of a fresh local one, point DEVSTACK_STACK at that stack and set DEVSTACK_TEST_REUSE=1 — the
boot seam then reuses the running stack instead of booting and tearing down its own. See
Running against an already-deployed stack.
Unit tests
Unit tests exercise generated bindings in isolation. A common one is a BCS round-trip: take a
generated MoveStruct, serialize a value to bytes, parse it back, and assert equality — this is the
same codec that decodes on-chain object content.
import { describe, expect, it } from 'vitest';
import { Counter } from '@generated/bindings/counter/counter.js';
describe('Counter BCS codec', () => {
it('round-trips a value through encode → decode', () => {
const counter = {
id: '0x0000000000000000000000000000000000000000000000000000000000000001',
owner: '0x0000000000000000000000000000000000000000000000000000000000000002',
value: '42',
};
const decoded = Counter.parse(Counter.serialize(counter).toBytes());
expect(decoded).toEqual(counter);
});
});These need no stack and no on-chain ids: import the bindings only, never @generated/config.js.
E2e tests
E2e tests run real transactions against a freshly booted local stack.
devstackVitestTestConfig({ autoBoot: true }) boots a test stack before the run and tears it down
after, so the suite is self-contained, and scopes the run to tests/e2e/.
import {
devstackVitestServerConfig,
devstackVitestTestConfig,
} from '@mysten-incubation/devstack/vitest';
import { defineConfig } from 'vitest/config';
export default defineConfig({
server: devstackVitestServerConfig(),
test: devstackVitestTestConfig({ autoBoot: true, test: { testTimeout: 60_000 } }),
});Inside a test, read the active stack's connection and package ids from the generated config, then build and sign transactions:
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { describe, expect, it } from 'vitest';
import { config } from '@generated/config.js';
import { createCounterTx, incrementTx, readCounter } from '../../src/counter.js';
const net = config.forNetwork(config.defaultNetwork);
describe('counter (local devstack)', () => {
it('creates a shared Counter and increments it', async () => {
const client = new SuiGrpcClient({
network: 'localnet',
baseUrl: net.rpc,
// Resolve the bindings' `@local/counter` placeholder to the deployed id.
mvr: { overrides: net.mvrOverrides },
});
const signer = new Ed25519Keypair();
// …fund `signer` from `net.faucet`, then create + increment…
expect(await readCounter(client, id)).toBe(1n);
});
});config.forNetwork(config.defaultNetwork) returns the booted test stack's connection entry
(rpc, faucet, …), and net.mvrOverrides resolves the package placeholders to the ids that stack
published. See Vitest for the config helpers and how a test
picks up the live ids.
Browser tests
Browser tests drive the app in a real browser with Playwright, signing through the injected dev
wallet. Your test switches the connected account with connectAs; every transaction is signed by
the app through its dapp-kit wallet adapter, and you assert the result by reading what the app
renders.
import { expect, test } from '@playwright/test';
import { connectAs } from '@mysten-incubation/devstack/playwright';
test('connects, creates a counter, and increments it on chain', async ({ page }) => {
await page.goto('/');
await connectAs(page, 'alice'); // a pre-funded account from devstack.config.ts
await expect(page.getByText(/Connected as/)).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Create counter' }).click();
const value = page.locator('span.value');
await expect(value).toHaveText('0', { timeout: 30_000 });
await page.getByRole('button', { name: 'Increment' }).click();
await expect(value).toHaveText('1', { timeout: 30_000 });
});connectAs(page, name) switches the dev wallet's active account through the dApp Kit test bridge
the app registers (see Playwright);
selectAccount(page, name) is the lower-level switch it wraps, and createWalletAdapter rounds out
the helper set. For multi-account flows, call connectAs between turns to alternate seats, and
switchNetwork(page, network) to move across networks. Because DEVSTACK_AUTO_APPROVE=1 is set for
the suite, signatures are approved with no UI step.
The example above asserts on what the app renders. To prove a transaction was actually signed and
executed, sign through the dev wallet explicitly with createWalletAdapter().signTransaction(...)
and then read the created object off the stack's RPC via loadStackManifest().endpoint('sui-rpc').
See
Asserting a signed transaction.
See Playwright for the config helpers and the wallet bridge, and Accounts & wallet for declaring the accounts these tests connect as.