Skip to content

Testing Best Practices

Testing Pyramid

Structure your tests in three layers:

        ┌─────────┐
        │   E2E   │  Few — critical user flows
        ├─────────┤
        │  Integ  │  Some — component interactions
        ├─────────┤
        │  Unit   │  Many — signals, stores, utils
        └─────────┘
LayerWhat to testToolSpeed
UnitSignals, computed, stores, utils, validatorsVitest~1ms per test
IntegrationMounted components, user interactions, form flowsVitest + mount()~10ms per test
E2EFull user journeys (login, checkout, navigation)Playwright~1-5s per test

Test behavior, not implementation

Do not test internal signal values. Test what the user sees and does.

ts
// DON'T: test implementation details
it('sets the count signal to 1', () => {
  const { component } = mount(Counter);
  // reaching into internal signals is fragile
  expect(component.__internal_count()).toBe(0);
});
ts
// DO: test user-visible behavior
it('increments the displayed count on click', async () => {
  const { getByRole, getByText, unmount } = mount(Counter, { props: { initial: 0 } });
  await fireEvent.click(getByRole('button'));
  expect(getByText('Count: 1')).toBeTruthy();
  unmount();
});

Unit Testing Signals and Stores

Signals and stores are plain TypeScript -- test them without mounting any component.

ts
import { describe, it, expect, beforeEach } from 'vitest';
import { signal, computed, batch } from '@akashjs/runtime';
import { clearStores } from '@akashjs/runtime';
import { useCartStore } from '@/stores/cart.store';

beforeEach(() => {
  clearStores();
});

describe('useCartStore', () => {
  it('starts empty', () => {
    const cart = useCartStore();
    expect(cart.items()).toEqual([]);
    expect(cart.totalItems()).toBe(0);
  });

  it('adds items', () => {
    const cart = useCartStore();
    cart.addItem('abc');
    expect(cart.totalItems()).toBe(1);
  });

  it('increments existing items', () => {
    const cart = useCartStore();
    cart.addItem('abc');
    cart.addItem('abc');
    expect(cart.items()).toHaveLength(1);
    expect(cart.items()[0].qty).toBe(2);
  });

  it('resets to initial state', () => {
    const cart = useCartStore();
    cart.addItem('abc');
    cart.$reset();
    expect(cart.items()).toEqual([]);
  });
});

Integration Testing with mount()

Use mount() to render a component into a detached DOM and query it.

ts
import { describe, it, expect } from 'vitest';
import { mount, fireEvent } from '@akashjs/runtime/test';
import LoginForm from './LoginForm.akash';

describe('LoginForm', () => {
  it('disables submit when form is invalid', () => {
    const { query, unmount } = mount(LoginForm);
    const button = query('button[type="submit"]');
    expect(button?.disabled).toBe(true);
    unmount();
  });

  it('enables submit when fields are filled', async () => {
    const { query, unmount } = mount(LoginForm);

    await fireEvent.input(query('input[name="email"]')!, 'alice@test.com');
    await fireEvent.input(query('input[name="password"]')!, 'password123');

    const button = query('button[type="submit"]');
    expect(button?.disabled).toBe(false);
    unmount();
  });

  it('calls onSubmit with form values', async () => {
    const handler = vi.fn();
    const { query, unmount } = mount(LoginForm, { props: { onSubmit: handler } });

    await fireEvent.input(query('input[name="email"]')!, 'alice@test.com');
    await fireEvent.input(query('input[name="password"]')!, 'password123');
    await fireEvent.submit(query('form')!);

    expect(handler).toHaveBeenCalledWith({
      email: 'alice@test.com',
      password: 'password123',
    });
    unmount();
  });
});

Always call unmount()

Forgetting to unmount leaks DOM nodes and effects between tests. Call unmount() in each test or use afterEach.

Mocking Stores

Use provide in mount options to inject mock values via context:

ts
import { mount } from '@akashjs/runtime/test';
import { ThemeContext } from '@/contexts/theme';
import ThemedButton from './ThemedButton.akash';

it('renders with dark theme', () => {
  const { query, unmount } = mount(ThemedButton, {
    provide: new Map([[ThemeContext, 'dark']]),
  });

  expect(query('.btn')?.classList.contains('dark')).toBe(true);
  unmount();
});

For stores, clear and pre-populate:

ts
import { clearStores } from '@akashjs/runtime';
import { useAuthStore } from '@/stores/auth.store';

beforeEach(() => {
  clearStores();
});

it('shows user name when logged in', () => {
  const auth = useAuthStore();
  auth.setCredentials({ name: 'Alice' }, 'token-123');

  const { getByText, unmount } = mount(UserBadge);
  expect(getByText('Alice')).toBeTruthy();
  unmount();
});

Testing Async Operations

Use await with fireEvent to wait for effects to flush, and use vi.fn() for mocking async calls.

ts
it('loads data and renders', async () => {
  vi.spyOn(http, 'get').mockResolvedValue([
    { id: '1', name: 'Alice' },
    { id: '2', name: 'Bob' },
  ]);

  const { getByText, unmount } = mount(UserList);

  // Wait for the resource to resolve
  await vi.waitFor(() => {
    expect(getByText('Alice')).toBeTruthy();
  });

  expect(getByText('Bob')).toBeTruthy();
  unmount();
});

Testing Forms

Forms are signal-based -- test the form object directly for unit tests.

ts
import { defineForm, required, email } from '@akashjs/forms';

describe('signup form validation', () => {
  it('rejects empty email', async () => {
    const form = defineForm({
      email: { initial: '', validators: [required(), email()] },
    });

    const handler = vi.fn();
    await form.submit(handler);
    expect(handler).not.toHaveBeenCalled();
    expect(form.fields.email.errors()).toContain('Required');
  });

  it('accepts valid email', async () => {
    const form = defineForm({
      email: { initial: 'a@b.com', validators: [required(), email()] },
    });

    const handler = vi.fn();
    await form.submit(handler);
    expect(handler).toHaveBeenCalledWith({ email: 'a@b.com' });
  });
});

Test File Structure

Co-locate tests with source files:

features/
  dashboard/
    Dashboard.akash
    Dashboard.test.ts       ← integration test
    dashboard.store.ts
    dashboard.store.test.ts ← unit test

Or use a __tests__ directory if you prefer separation:

features/
  dashboard/
    __tests__/
      Dashboard.test.ts
      dashboard.store.test.ts
    Dashboard.akash
    dashboard.store.ts

Snapshot Testing Signals

Snapshots work well for store state:

ts
it('matches snapshot after operations', () => {
  const store = useCartStore();
  store.addItem('product-1');
  store.addItem('product-2');
  store.addItem('product-1');

  expect(store.$snapshot()).toMatchInlineSnapshot(`
    {
      "items": [
        { "id": "product-1", "qty": 2 },
        { "id": "product-2", "qty": 1 },
      ],
    }
  `);
});

Do not snapshot DOM output

DOM snapshots are brittle and fail on every markup change. Test behavior instead.

Leak Detection in Tests

Enable leak detection in test setup to catch effects that are not cleaned up:

ts
import { enableLeakDetection, checkForLeaks } from '@akashjs/runtime';

beforeAll(() => {
  enableLeakDetection();
});

afterEach(() => {
  const leaks = checkForLeaks(5000);
  if (leaks.length > 0) {
    console.warn('Leaked effects:', leaks);
  }
  expect(leaks).toHaveLength(0);
});

Released under the MIT License.