Skip to content

Step 9: Testing ​

In this step, you will write tests for the TodoItem component, the todo store, and form validation using AkashJS test utilities and Vitest.

Testing Philosophy ​

AkashJS testing is designed to be simple:

  • No TestBed -- no module configuration or compileComponents()
  • No shallow rendering -- mount real components into a real (jsdom) DOM
  • Signal-aware -- effects flush automatically between interactions

The test utilities are in @akashjs/runtime/test:

ts
import { mount, fireEvent } from '@akashjs/runtime/test';

Configure Vitest ​

Your project already has Vitest installed. Make sure your vitest.config.ts (or vite.config.ts) includes the AkashJS plugin:

ts
import { defineConfig } from 'vitest/config';
import akash from '@akashjs/vite-plugin';

export default defineConfig({
  plugins: [akash()],
  test: {
    environment: 'jsdom',
    globals: true,
  },
});

Why jsdom?

AkashJS renders real DOM nodes, not a virtual DOM. Tests need a DOM environment. The jsdom environment provides this without a real browser.

Test the TodoItem Component ​

Create src/components/__tests__/TodoItem.test.ts:

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

describe('TodoItem', () => {
  const baseTodo = {
    id: '1',
    text: 'Test todo',
    completed: false,
    createdAt: Date.now(),
  };

  it('renders the todo text', () => {
    const { getByText } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle: vi.fn(),
        onDelete: vi.fn(),
        onEdit: vi.fn(),
      },
    });

    expect(getByText('Test todo')).toBeTruthy();
  });

  it('renders a checkbox that reflects completed state', () => {
    const { container } = mount(TodoItem, {
      props: {
        todo: { ...baseTodo, completed: true },
        onToggle: vi.fn(),
        onDelete: vi.fn(),
        onEdit: vi.fn(),
      },
    });

    const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
    expect(checkbox).toBeTruthy();
    expect(checkbox.checked).toBe(true);
  });

  it('calls onToggle when checkbox is clicked', async () => {
    const onToggle = vi.fn();
    const { container } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle,
        onDelete: vi.fn(),
        onEdit: vi.fn(),
      },
    });

    const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
    await fireEvent.click(checkbox);

    expect(onToggle).toHaveBeenCalledWith('1');
  });

  it('calls onDelete when delete button is clicked', async () => {
    const onDelete = vi.fn();
    const { getByText } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle: vi.fn(),
        onDelete,
        onEdit: vi.fn(),
      },
    });

    const deleteBtn = getByText('Delete');
    await fireEvent.click(deleteBtn);

    expect(onDelete).toHaveBeenCalledWith('1');
  });

  it('enters edit mode when edit button is clicked', async () => {
    const { getByText, container } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle: vi.fn(),
        onDelete: vi.fn(),
        onEdit: vi.fn(),
      },
    });

    const editBtn = getByText('Edit');
    await fireEvent.click(editBtn);

    const input = container.querySelector('.edit-input') as HTMLInputElement;
    expect(input).toBeTruthy();
    expect(input.value).toBe('Test todo');
  });

  it('calls onEdit when saving an edit', async () => {
    const onEdit = vi.fn();
    const { getByText, container } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle: vi.fn(),
        onDelete: vi.fn(),
        onEdit,
      },
    });

    // Enter edit mode
    await fireEvent.click(getByText('Edit'));

    // Change the text
    const input = container.querySelector('.edit-input') as HTMLInputElement;
    await fireEvent.input(input, 'Updated todo');

    // Save by pressing Enter
    await fireEvent.keyDown(input, 'Enter');

    expect(onEdit).toHaveBeenCalledWith('1', 'Updated todo');
  });

  it('cancels edit on Escape', async () => {
    const onEdit = vi.fn();
    const { getByText, container } = mount(TodoItem, {
      props: {
        todo: baseTodo,
        onToggle: vi.fn(),
        onDelete: vi.fn(),
        onEdit,
      },
    });

    await fireEvent.click(getByText('Edit'));

    const input = container.querySelector('.edit-input') as HTMLInputElement;
    await fireEvent.input(input, 'Changed text');
    await fireEvent.keyDown(input, 'Escape');

    // Should not call onEdit
    expect(onEdit).not.toHaveBeenCalled();

    // Should exit edit mode and show original text
    expect(getByText('Test todo')).toBeTruthy();
  });
});

Key Testing Patterns ​

mount(Component, { props }) renders the component into a real DOM element and returns query helpers:

  • getByText(text) -- find element by text content (throws if not found)
  • getByRole(role) -- find by ARIA role
  • getByTestId(id) -- find by data-testid attribute
  • query(selector) -- CSS selector, returns null if not found
  • queryAll(selector) -- CSS selector, returns array
  • container -- the root DOM element

fireEvent.click(el) dispatches a real DOM event and waits for effects to flush. Always await it.

fireEvent.input(el, value) sets an input's value and dispatches input + change events.

Test the Todo Store ​

Create src/stores/__tests__/todo-store.test.ts:

ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useTodoStore } from '../todo-store';
import { clearStores } from '@akashjs/runtime';

describe('useTodoStore', () => {
  beforeEach(() => {
    // Reset store singleton between tests
    clearStores();
    localStorage.clear();
  });

  it('starts with empty items', () => {
    const store = useTodoStore();
    expect(store.items()).toEqual([]);
    expect(store.totalCount()).toBe(0);
  });

  it('adds a todo', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');

    expect(store.items().length).toBe(1);
    expect(store.items()[0].text).toBe('Buy milk');
    expect(store.items()[0].completed).toBe(false);
    expect(store.totalCount()).toBe(1);
    expect(store.activeCount()).toBe(1);
  });

  it('toggles a todo', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');

    const id = store.items()[0].id;
    store.toggleTodo(id);

    expect(store.items()[0].completed).toBe(true);
    expect(store.completedCount()).toBe(1);
    expect(store.activeCount()).toBe(0);
  });

  it('deletes a todo', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');
    store.addTodo('Walk the dog');

    const id = store.items()[0].id;
    store.deleteTodo(id);

    expect(store.items().length).toBe(1);
    expect(store.items()[0].text).toBe('Walk the dog');
  });

  it('edits a todo', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');

    const id = store.items()[0].id;
    store.editTodo(id, 'Buy oat milk');

    expect(store.items()[0].text).toBe('Buy oat milk');
  });

  it('clears completed todos', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');
    store.addTodo('Walk the dog');
    store.addTodo('Read a book');

    // Complete the first two
    store.toggleTodo(store.items()[0].id);
    store.toggleTodo(store.items()[1].id);

    store.clearCompleted();

    expect(store.items().length).toBe(1);
    expect(store.items()[0].text).toBe('Read a book');
  });

  it('filters by active', () => {
    const store = useTodoStore();
    store.addTodo('Active todo');
    store.addTodo('Completed todo');
    store.toggleTodo(store.items()[1].id);

    store.setFilter('active');
    expect(store.filteredTodos().length).toBe(1);
    expect(store.filteredTodos()[0].text).toBe('Active todo');
  });

  it('filters by completed', () => {
    const store = useTodoStore();
    store.addTodo('Active todo');
    store.addTodo('Completed todo');
    store.toggleTodo(store.items()[1].id);

    store.setFilter('completed');
    expect(store.filteredTodos().length).toBe(1);
    expect(store.filteredTodos()[0].text).toBe('Completed todo');
  });

  it('resets to initial state', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');
    store.setFilter('active');

    store.$reset();

    expect(store.items()).toEqual([]);
    expect(store.filter()).toBe('all');
  });

  it('takes a snapshot', () => {
    const store = useTodoStore();
    store.addTodo('Buy milk');

    const snapshot = store.$snapshot();

    expect(snapshot.items.length).toBe(1);
    expect(snapshot.filter).toBe('all');
    expect(snapshot.loading).toBe(false);
  });
});

Test Form Validation ​

Create src/components/__tests__/AddTodoForm.test.ts:

ts
import { describe, it, expect, vi } from 'vitest';
import { defineForm } from '@akashjs/forms';
import { required, minLength, maxLength } from '@akashjs/forms';

describe('AddTodo form validation', () => {
  function createForm() {
    return defineForm({
      text: {
        initial: '',
        validators: [
          required('Todo text is required'),
          minLength(2, 'Must be at least 2 characters'),
          maxLength(200, 'Must be under 200 characters'),
        ],
      },
    });
  }

  it('starts invalid with empty text', () => {
    const form = createForm();
    expect(form.valid()).toBe(false);
  });

  it('shows required error on empty submit', () => {
    const form = createForm();
    const handler = vi.fn();

    form.submit(handler);

    expect(handler).not.toHaveBeenCalled();
    expect(form.fields.text.errors()).toContain('Todo text is required');
  });

  it('shows minLength error for short text', () => {
    const form = createForm();
    form.fields.text.value.set('a');
    form.fields.text.markTouched();

    expect(form.fields.text.errors()).toContain('Must be at least 2 characters');
  });

  it('is valid with proper text', () => {
    const form = createForm();
    form.fields.text.value.set('Buy groceries');

    expect(form.valid()).toBe(true);
    expect(form.fields.text.errors()).toEqual([]);
  });

  it('calls handler on valid submit', async () => {
    const form = createForm();
    const handler = vi.fn();

    form.fields.text.value.set('Buy groceries');
    await form.submit(handler);

    expect(handler).toHaveBeenCalledWith({ text: 'Buy groceries' });
  });

  it('resets the form', () => {
    const form = createForm();
    form.fields.text.value.set('Something');
    form.fields.text.markTouched();

    form.reset();

    expect(form.fields.text.value()).toBe('');
    expect(form.fields.text.touched()).toBe(false);
    expect(form.dirty()).toBe(false);
  });
});

Run the Tests ​

bash
pnpm test

You should see output like:

 ✓ src/components/__tests__/TodoItem.test.ts (7 tests)
 ✓ src/stores/__tests__/todo-store.test.ts (10 tests)
 ✓ src/components/__tests__/AddTodoForm.test.ts (6 tests)

 Test Files  3 passed (3)
      Tests  23 passed (23)
   Start at  10:30:00
   Duration  1.2s

Testing Strategy

For AkashJS apps, a good testing pyramid is:

  1. Store tests (fast, pure logic) -- test all actions and getters
  2. Form validation tests (fast, pure logic) -- test validators and submission flow
  3. Component tests (medium, DOM) -- test rendering and user interaction
  4. E2E tests (slow, browser) -- test critical user journeys with Playwright

Focus most effort on store and form tests. They are fast, reliable, and cover the most important logic.

Try It ​

Extend the tests:

  1. Add a test that verifies the TodoItem shows strikethrough styling when completed
  2. Add a store test for hasCompleted -- should return false when no items are completed
  3. Add a form test that checks the maxLength validator with a 201-character string
  4. Run pnpm test --watch to get live test feedback as you code

Summary ​

You now know how to:

  • Mount components with mount() and query the resulting DOM
  • Simulate user interaction with fireEvent (click, input, keyDown)
  • Test stores by calling actions and asserting getter values
  • Test form validation logic independently of the DOM
  • Run tests with Vitest in a jsdom environment

The app is built and tested. Let's ship it.


What's Next: Deployment -- build for production and deploy your todo app.

Released under the MIT License.