Testing and QA
Comprehensive guide to running tests, ensuring code quality, and writing new tests for the Asset Tokenization Kit
This guide covers the testing infrastructure across the Asset Tokenization Kit monorepo, including unit tests, smart contract tests, E2E tests, and continuous integration practices.
Testing architecture
The kit employs a multi-layered testing strategy:
Test locations
kit/
├── contracts/
│ └── test/ # Foundry smart contract tests
│ https://github.com/settlemint/asset-tokenization-kit/tree/main/kit/contracts/test
├── dapp/
│ └── test/ # Vitest unit tests for frontend
│ https://github.com/settlemint/asset-tokenization-kit/tree/main/kit/dapp/test
├── subgraph/
│ └── test/ # Matchstick subgraph tests
│ https://github.com/settlemint/asset-tokenization-kit/tree/main/kit/subgraph/test
└── e2e/
├── ui-tests/ # Playwright browser automation tests
│ https://github.com/settlemint/asset-tokenization-kit/tree/main/kit/e2e/ui-tests
└── test-data/ # Shared test fixtures
https://github.com/settlemint/asset-tokenization-kit/tree/main/kit/e2e/test-data
Playwright fixtures use `node:crypto` helpers (`randomInt`, `randomBytes`) to generate
unique emails, PIN codes, and identifiers. When extending `kit/e2e/test-data`, prefer
these secure functions over `Math.random()` so that test users remain collision-free in
parallel runs and align with the platform's security posture.Running tests
Run the complete test suite across all packages:
# From repository root
bun run ciThis executes:
- Format checking (Prettier)
- TypeScript compilation
- Code generation (contract types, subgraph schemas)
- Linting (ESLint, Solhint)
- Type checking
- Unit tests (all packages)
- Build verification
For faster iteration during development:
# Skip integration tests
bun run ci:baseThe DApp uses Vitest for unit testing React components, utilities, and business logic.
Run all unit tests:
cd kit/dapp
bun run test:unitRun with UI mode:
bun run test:unit:uiOpens browser at http://localhost:51204/__vitest__/ with:
- Test file tree
- Real-time test execution
- Coverage visualization
- Source code viewer
Run specific test file:
bun run test:unit -- path/to/file.test.tsWatch mode:
Auto-rerun tests on file changes:
bun run test:unit:watchCoverage report:
bun run test:unit:coverageView coverage report at
kit/dapp/coverage/index.html:
- Line coverage
- Branch coverage
- Function coverage
- Uncovered lines highlighted
The contracts package uses Foundry for Solidity testing with gas tracking and fuzzing.
Run all contract tests:
cd kit/contracts
bun run testOr via Turbo from root:
bunx turbo run test --filter=contractsRun specific test contract:
forge test --match-contract BondTokenTestRun specific test function:
forge test --match-test test_IssueBondGas report:
forge test --gas-reportShows gas consumption per function call:
| Function | min | avg | max | calls |
|-----------------|------|-------|-------|-------|
| issueBond | 2431 | 42341 | 82251 | 12 |
| transfer | 1234 | 23456 | 45678 | 24 |Coverage report:
bun run test:ciCreates LCOV report at
kit/contracts/coverage/lcov.info.
Fuzzing:
Foundry automatically fuzzes test inputs. Customize fuzz runs:
// In test contract
function testFuzz_Transfer(uint256 amount) public {
vm.assume(amount > 0 && amount <= maxSupply);
// Test logic
}Adjust fuzz runs in foundry.toml:
[fuzz]
runs = 256
max_test_rejects = 65536The subgraph uses Matchstick (AssemblyScript testing framework).
Run all subgraph tests:
cd kit/subgraph
bun run testRun specific test file:
graph test test/bond-token.test.tsDebug mode:
graph test --debugThe E2E suite uses Playwright for full-stack integration testing across UI and API.
Prerequisites:
Start the full development environment:
# From repository root
bun run dev:up
bun run artifacts
bun run --cwd kit/dapp db:migrateWait for all services to be healthy:
- PostgreSQL (port 5432)
- Redis (port 6379)
- Besu node (port 8545)
- DApp (port 3000)
- Hasura (port 8080)
Once the containers are healthy, run the Drizzle migrations (see above) to
create baseline tables such as settings before starting the Playwright
runner. Without this step the dApp boot check fails with relation "settings" does not exist.
Readiness tip: CI probes
http://localhost:3000/auth/sign-inwhile waiting for the server to come up. If you customize the startup flow locally, make sure this public auth route (or an equivalent unauthenticated route) keeps returning200so the readiness check succeeds (READINESS_PATHin the workflow controls it).
Run all UI tests:
cd kit/e2e
bun run test:uiRun UI tests with browser visible:
bun run test:ui:headedRun specific test file:
bun run test:ui -- ui-tests/bond-token.spec.tsRun API tests:
bun run test:apiAPI tests validate GraphQL endpoints, ORPC procedures, and backend logic without browser interaction.
Debug mode:
bun run test:ui:debugOpens Playwright Inspector for step-by-step debugging:
- Set breakpoints
- Inspect DOM snapshots
- View network requests
- Console logs
Playwright UI mode:
Interactive test runner:
bun run test:ui:ui-modeFeatures:
- Watch mode with auto-rerun
- Time travel debugging
- Trace viewer
- Visual test picker
Locator strategy tips:
- Prefer
page.getByLabel()for form inputs so required indicators or tooltip icons do not break accessible-name matching. - Scope locators with
has,hasText, or.locator()chains whenever multiple widgets share the same label to keep Playwright strict-mode happy. - Avoid hard-coding decorative characters (such as
*) in regexes; rely on the underlying accessible name exposed to assistive tech. - When components keep hidden templates, narrow to the active container and use
the label's
forattribute (label.getAttribute('for') → page.locator(#id)), then descend from the label's container to reach the matching input so hidden template nodes are skipped. - For compound widgets (e.g., date pickers) reuse the resolved container and
locate controls inside it (
container.locator('button#dob')) before invoking shared helpers.
Test project structure (E2E)
Playwright tests are organized into dependent projects (see
kit/e2e/playwright.ui.config.ts):
- setup: Complete onboarding flow (runs first)
- transfer-users: Create test users for transfers
- ui-tests: Main UI test suite (depends on setup)
- stablecoin-tests: Stablecoin validation
- deposit-tests: Deposit product tests
- equity-tests: Equity token tests
- fund-tests: Investment fund tests
- bond-tests: Bond issuance tests
- cleanup: Global teardown (runs last)
Run specific project:
bun run test:ui --project=bond-testsWriting new tests
Unit tests (DApp)
Create test files adjacent to source files with .test.ts extension:
src/
├── components/
│ ├── bond-form.tsx
│ └── bond-form.test.tsxExample: component test
// kit/dapp/src/components/bond-form.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { BondForm } from './bond-form';
describe('BondForm', () => {
it('submits bond creation with valid inputs', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<BondForm onSubmit={onSubmit} />);
// Fill form
await user.type(screen.getByLabelText(/name/i), 'Corporate Bond 2024');
await user.type(screen.getByLabelText(/symbol/i), 'CORP24');
await user.type(screen.getByLabelText(/face value/i), '1000');
// Submit
await user.click(screen.getByRole('button', { name: /create/i }));
// Verify
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Corporate Bond 2024',
symbol: 'CORP24',
faceValue: 1000,
});
});
});
it('displays validation errors for invalid inputs', async () => {
render(<BondForm onSubmit={vi.fn()} />);
// Submit empty form
await userEvent.click(screen.getByRole('button', { name: /create/i }));
// Check errors
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/symbol is required/i)).toBeInTheDocument();
});
});Example: utility test
// kit/dapp/src/lib/format-currency.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./format-currency";
describe("formatCurrency", () => {
it("formats USD amounts with commas", () => {
expect(formatCurrency(1234567.89, "USD")).toBe("$1,234,567.89");
});
it("handles zero values", () => {
expect(formatCurrency(0, "USD")).toBe("$0.00");
});
it("supports different currencies", () => {
expect(formatCurrency(1000, "EUR")).toBe("€1,000.00");
expect(formatCurrency(1000, "GBP")).toBe("£1,000.00");
});
});Smart contract tests
Create test contracts in
kit/contracts/test/
following the pattern ContractName.t.sol:
test/
├── BondToken.t.sol
├── EquityToken.t.sol
└── fixtures/
└── TestHelpers.solExample: Foundry test
// kit/contracts/test/BondToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import "../contracts/BondToken.sol";
contract BondTokenTest is Test {
BondToken public bond;
address public issuer = address(1);
address public investor = address(2);
function setUp() public {
vm.startPrank(issuer);
bond = new BondToken(
"Corporate Bond",
"CORP",
1000e18, // face value
5, // coupon rate (5%)
365 days // maturity
);
vm.stopPrank();
}
function test_IssueBond() public {
vm.prank(issuer);
bond.issue(investor, 10);
assertEq(bond.balanceOf(investor), 10);
assertEq(bond.totalSupply(), 10);
}
function test_RevertWhen_NonIssuerIssues() public {
vm.prank(investor);
vm.expectRevert("Only issuer can issue bonds");
bond.issue(investor, 10);
}
function testFuzz_Transfer(uint256 amount) public {
vm.assume(amount > 0 && amount <= 1000);
// Issue bonds
vm.prank(issuer);
bond.issue(investor, amount);
// Transfer
vm.prank(investor);
bond.transfer(address(3), amount / 2);
assertEq(bond.balanceOf(investor), amount - (amount / 2));
assertEq(bond.balanceOf(address(3)), amount / 2);
}
}Test helpers
Use Foundry cheatcodes:
vm.prank(address): Set msg.sender for next callvm.startPrank(address): Set msg.sender until stopPrankvm.expectRevert(string): Expect next call to revertvm.warp(timestamp): Set block.timestampvm.roll(blockNumber): Set block.numbervm.deal(address, amount): Set ETH balance
E2E tests
Create test files in
kit/e2e/ui-tests/
with .spec.ts extension.
Example: Playwright test
// kit/e2e/ui-tests/bond-issuance.spec.ts
import { test, expect } from "@playwright/test";
import { loginAs } from "../utils/auth-helpers";
import { createBond } from "../utils/bond-helpers";
test.describe("Bond Issuance", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "[email protected]");
});
test("creates new bond with valid parameters", async ({ page }) => {
await page.goto("/bonds/new");
// Fill form
await page.getByLabel("Bond Name").fill("Green Bond 2024");
await page.getByLabel("Symbol").fill("GREEN24");
await page.getByLabel("Face Value").fill("1000");
await page.getByLabel("Coupon Rate (%)").fill("4.5");
await page.getByLabel("Maturity Date").fill("2029-12-31");
// Submit
await page.getByRole("button", { name: /create bond/i }).click();
// Verify success
await expect(page.getByText(/bond created successfully/i)).toBeVisible();
// Check bond appears in list
await page.goto("/bonds");
await expect(page.getByText("Green Bond 2024")).toBeVisible();
});
test("validates required fields", async ({ page }) => {
await page.goto("/bonds/new");
// Submit without filling
await page.getByRole("button", { name: /create bond/i }).click();
// Check validation messages
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page.getByText(/symbol is required/i)).toBeVisible();
});
test("displays bond details after creation", async ({ page }) => {
const bond = await createBond(page, {
name: "Test Bond",
symbol: "TEST",
faceValue: 1000,
couponRate: 5,
maturityDate: "2030-01-01",
});
await page.goto(`/bonds/${bond.id}`);
await expect(
page.getByRole("heading", { name: "Test Bond" })
).toBeVisible();
await expect(page.getByText("$1,000.00")).toBeVisible();
await expect(page.getByText("5%")).toBeVisible();
});
});Using test fixtures
Create reusable test data:
// kit/e2e/test-data/bonds.ts
export const SAMPLE_BONDS = {
corporateBond: {
name: "Corporate Bond 2024",
symbol: "CORP24",
faceValue: 1000,
couponRate: 4.5,
maturityDate: "2029-12-31",
},
governmentBond: {
name: "Treasury Bond",
symbol: "TREAS",
faceValue: 5000,
couponRate: 3.0,
maturityDate: "2034-06-30",
},
};Use in tests:
import { SAMPLE_BONDS } from "../test-data/bonds";
test("creates corporate bond", async ({ page }) => {
await createBond(page, SAMPLE_BONDS.corporateBond);
// ...
});Linting and code quality
Run all linters
bun run lintThis runs:
- ESLint for TypeScript/JavaScript
- Solhint for Solidity
- Prettier (check mode)
- Fumadocs link validation to fail builds on broken doc URLs
Solidity linting
cd kit/contracts
bun run lintEnforces rules from .solhint.json:
- Best practices
- Security patterns
- Gas optimization
- Naming conventions
Fix auto-fixable issues:
solhint --fix contracts/**/*.solType checking
Run TypeScript compiler in check mode:
bun run typecheckOr per package:
bunx turbo run typecheck --filter=dapp
bunx turbo run typecheck --filter=contractsContinuous integration
The GitHub Actions workflow (.github/workflows/qa.yml) runs on every pull
request and push to main.
CI pipeline stages
-
Setup:
- Checkout repository
- Install Bun and dependencies
- Configure Docker and databases
- Apply Drizzle migrations (retries until PostgreSQL is ready)
-
Artifact generation:
- Compile smart contracts (Forge + Hardhat)
- Generate TypeScript types from ABIs
- Create genesis allocations
-
Quality checks:
- Format verification (Prettier)
- Linting (ESLint, Solhint)
- Type checking (TypeScript)
-
Test execution:
- Unit tests (Vitest, Foundry)
- Subgraph tests (Matchstick)
- E2E tests (Playwright)
-
Chart testing (conditional):
- Helm chart linting
- Deploy to ephemeral K8s cluster
- Validate all resources
-
Build verification:
- Build DApp bundle
- Build Docker images
- Push to container registry
Running CI locally
Reproduce CI environment:
# Full CI suite
bun run ci
# Faster subset
bun run ci:baseTest coverage reporting
Coverage is uploaded to Coveralls on main branch:
# Generate coverage for all packages
bunx turbo run test:ci --concurrency=100%View coverage at:
https://coveralls.io/github/settlemint/asset-tokenization-kit
Test best practices
Unit tests
-
Test behavior, not implementation
// Good: Tests behavior expect(screen.getByText("Bond created")).toBeVisible(); // Bad: Tests implementation detail expect(component.state.bondCreated).toBe(true); -
Use meaningful test names
// Good test('displays error when face value is negative', ...); // Bad test('test1', ...); -
Arrange-Act-Assert pattern
test('submits form', async () => { // Arrange const onSubmit = vi.fn(); render(<Form onSubmit={onSubmit} />); // Act await userEvent.click(screen.getByRole('button')); // Assert expect(onSubmit).toHaveBeenCalled(); }); -
Avoid testing library internals
Don't test React Query, TanStack Router, or third-party libraries. Test your code's integration with them.
Contract tests
-
Test edge cases and boundaries
function test_TransferZeroAmount() public { vm.expectRevert("Cannot transfer zero"); bond.transfer(address(1), 0); } function test_TransferMaxAmount() public { bond.transfer(address(1), type(uint256).max); } -
Use fuzz testing for input validation
function testFuzz_ValidCouponRate(uint8 rate) public { vm.assume(rate <= 100); // Valid rates: 0-100% bond.setCouponRate(rate); assertEq(bond.couponRate(), rate); } -
Test access control
function test_OnlyIssuerCanIssue() public { vm.prank(attacker); vm.expectRevert("Not authorized"); bond.issue(investor, 100); } -
Test events
function test_EmitsBondIssuedEvent() public { vm.expectEmit(true, true, true, true); emit BondIssued(investor, 100, block.timestamp); bond.issue(investor, 100); }
E2E tests
-
Use page object model for reusability
class BondPage { constructor(private page: Page) {} async createBond(data: BondData) { await this.page.getByLabel("Name").fill(data.name); await this.page.getByLabel("Symbol").fill(data.symbol); await this.page.getByRole("button", { name: /create/i }).click(); } } -
Wait for state, not timers
// Good await page.waitForURL("/bonds/123"); // Bad await page.waitForTimeout(3000); -
Clean up test data
test.afterEach(async ({ page }) => { await cleanupTestBonds(page); }); -
Use auto-retry for flaky operations
await expect .poll(async () => { const balance = await getBalance(address); return balance; }) .toBeGreaterThan(0);
Debugging test failures
Unit test failures
-
Check test output:
bun run test:unit 2>&1 | tee test-output.log -
Run in UI mode:
bun run test:unit:uiInspect component state, re-run individual tests.
-
Check coverage gaps:
bun run test:unit:coverageUncovered lines may indicate missing test cases.
Contract test failures
-
Verbose output:
forge test -vvvvShows:
- Stack traces
- Gas usage
- Storage changes
- Event logs
-
Debug specific test:
forge test --debug test_TransferFailsOpens interactive debugger.
-
Gas snapshot:
forge snapshotCompare gas changes between runs.
E2E test failures
-
View trace files:
Playwright saves traces on failure in
kit/e2e/test-results/:npx playwright show-trace test-results/bond-issuance/trace.zip -
Take screenshots:
await page.screenshot({ path: "debug.png", fullPage: true }); -
Enable verbose logging:
DEBUG=pw:api bun run test:ui -
Slow down execution:
test.use({ launchOptions: { slowMo: 500 } });
Next steps
- Review Deployment Guide for deploying to production environments
- See Development FAQ for common testing issues
- Explore Code Structure to understand test organization
- Consult API Reference for testing API procedures