Mysten Incubation
Testing

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.

SuiteDirectoryWhat you testHelper subpathStack
Unittests/unit/generated bindings@mysten-incubation/devstack/vite (alias only)none
E2etests/e2e/real transactions on a stack@mysten-incubation/devstack/vitesttest
Browsertests/browser/the running app + dev wallet@mysten-incubation/devstack/playwrighte2e

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

package.json
{
	"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 test runs the unit suite (tests/unit/). No stack, no Docker — plain Vitest over your generated bindings.
  • pnpm test:e2e boots a dedicated test stack (codegen included), runs the tests/e2e/ suite against it in node, and tears it down. Self-contained, and isolated from a running pnpm dev stack. test is the recommended stack name for non-browser tests so they don't contend with a developer's pnpm dev stack.
  • pnpm test:browser boots the e2e stack 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=1 makes the dev wallet sign without showing its approval UI.
  • DEVSTACK_E2E=1 is a dedicated signal — distinct from auto-approve — that the Vite plugin injects as the __DEVSTACK_E2E__ global. It gates dApp Kit's autoConnect on only under e2e, so a normal pnpm dev serve 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.

tests/unit/bcs.test.ts
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/.

vitest.e2e.config.ts
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:

tests/e2e/counter.test.ts
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.

tests/browser/counter.spec.ts
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.

On this page