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:
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:
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:
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 rolegetByTestId(id)-- find bydata-testidattributequery(selector)-- CSS selector, returns null if not foundqueryAll(selector)-- CSS selector, returns arraycontainer-- 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:
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:
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
pnpm testYou 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.2sTesting Strategy
For AkashJS apps, a good testing pyramid is:
- Store tests (fast, pure logic) -- test all actions and getters
- Form validation tests (fast, pure logic) -- test validators and submission flow
- Component tests (medium, DOM) -- test rendering and user interaction
- 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:
- Add a test that verifies the TodoItem shows strikethrough styling when completed
- Add a store test for
hasCompleted-- should return false when no items are completed - Add a form test that checks the
maxLengthvalidator with a 201-character string - Run
pnpm test --watchto 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.