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-dataRunning tests
Writing 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/**/*.solTypeScript/JavaScript linting
cd kit/dapp
bun run lintESLint configuration in eslint.config.ts enforces:
- React best practices
- Accessibility rules (jsx-a11y)
- TypeScript strict rules
- Import order
Fix auto-fixable issues:
bun run lint:fixFormatting
Check formatting without modifying files:
bun run format:checkAuto-format all files:
bun run formatPrettier configuration (.prettierrc):
- 2-space indentation
- Single quotes
- Trailing commas (ES5)
- 80-character line width
Type 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
-
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