testing
TDD patterns for React Native with Jest, Testing Library, and XState. Trigger: When writing tests, implementing TDD, or setting up test infrastructure.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
When to Use
- Starting a new feature (write tests FIRST)
- Testing React Native components
- Testing XState machines
- Testing custom hooks
- Testing domain logic and use cases
- Mocking native modules and external services
TDD Workflow
┌─────────────────────────────────────────────────────────┐
│ TDD CYCLE │
│ │
│ ┌───────┐ ┌───────┐ ┌──────────┐ │
│ │ RED │─────▶│ GREEN │─────▶│ REFACTOR │ │
│ └───────┘ └───────┘ └──────────┘ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
│ 1. RED: Write failing test first │
│ 2. GREEN: Write minimum code to pass │
│ 3. REFACTOR: Improve code, keep tests passing │
└─────────────────────────────────────────────────────────┘
TDD Rules
| Rule | Description |
|---|---|
| Test first | Write the test BEFORE the implementation |
| One test at a time | Focus on one behavior per test |
| Minimal code | Write just enough to make test pass |
| Refactor often | Clean up after each green |
| No production code without a failing test | Every line justified |
Installation
cd mobile
# Testing libraries
bun add -d jest @types/jest ts-jest
bun add -d @testing-library/react-native @testing-library/jest-native
bun add -d jest-expo
# For XState testing
bun add -d @xstate/test
Jest Configuration
// jest.config.js
module.exports = {
preset: 'jest-expo',
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
};
Test File Structure
feature/
├── domain/
│ ├── entities/
│ │ ├── VoiceCommand.ts
│ │ └── __tests__/
│ │ └── VoiceCommand.test.ts
│ └── services/
│ ├── WakeWordParser.ts
│ └── __tests__/
│ └── WakeWordParser.test.ts
├── application/
│ └── use-cases/
│ ├── ProcessCommand.ts
│ └── __tests__/
│ └── ProcessCommand.test.ts
├── machines/
│ ├── voiceMachine.ts
│ └── __tests__/
│ └── voiceMachine.test.ts
└── presentation/
├── hooks/
│ ├── useVoiceCommands.ts
│ └── __tests__/
│ └── useVoiceCommands.test.ts
└── components/
└── organisms/
├── VoiceCommandPanel.tsx
└── __tests__/
└── VoiceCommandPanel.test.tsx
Testing Domain Entities (TDD Example)
Step 1: RED - Write Failing Test
// domain/entities/__tests__/VoiceCommand.test.ts
import { VoiceCommand } from '../VoiceCommand';
describe('VoiceCommand', () => {
describe('isValid', () => {
it('should return true when confidence is above threshold', () => {
const command = new VoiceCommand({
text: 'iris describe',
confidence: 0.8,
timestamp: Date.now(),
});
expect(command.isValid()).toBe(true);
});
it('should return false when confidence is below threshold', () => {
const command = new VoiceCommand({
text: 'iris describe',
confidence: 0.5,
timestamp: Date.now(),
});
expect(command.isValid()).toBe(false);
});
});
describe('containsWakeWord', () => {
it('should detect wake word "iris" case-insensitive', () => {
const command = new VoiceCommand({
text: 'IRIS describe what you see',
confidence: 0.9,
timestamp: Date.now(),
});
expect(command.containsWakeWord()).toBe(true);
});
it('should return false when wake word is missing', () => {
const command = new VoiceCommand({
text: 'describe what you see',
confidence: 0.9,
timestamp: Date.now(),
});
expect(command.containsWakeWord()).toBe(false);
});
it('should allow custom wake word', () => {
const command = new VoiceCommand({
text: 'hey siri describe',
confidence: 0.9,
timestamp: Date.now(),
});
expect(command.containsWakeWord('siri')).toBe(true);
expect(command.containsWakeWord('iris')).toBe(false);
});
});
});
Step 2: GREEN - Implement to Pass
// domain/entities/VoiceCommand.ts
interface VoiceCommandProps {
text: string;
confidence: number;
timestamp: number;
}
export class VoiceCommand {
readonly text: string;
readonly confidence: number;
readonly timestamp: number;
private static CONFIDENCE_THRESHOLD = 0.7;
constructor({ text, confidence, timestamp }: VoiceCommandProps) {
this.text = text;
this.confidence = confidence;
this.timestamp = timestamp;
}
isValid(): boolean {
return this.confidence > VoiceCommand.CONFIDENCE_THRESHOLD;
}
containsWakeWord(wakeWord: string = 'iris'): boolean {
return this.text.toLowerCase().includes(wakeWord.toLowerCase());
}
}
Step 3: REFACTOR - Improve
// Add more tests, extract constants, improve naming
Testing Use Cases
// application/use-cases/__tests__/ProcessCommand.test.ts
import { ProcessCommandUseCase } from '../ProcessCommand';
import { VoiceCommand } from '../../domain/entities/VoiceCommand';
describe('ProcessCommandUseCase', () => {
// Mocks
const mockVisionService = {
analyzeScene: jest.fn(),
};
const mockSpeechSynthesizer = {
speak: jest.fn(),
};
const mockRepository = {
saveCommand: jest.fn(),
getLastDescription: jest.fn(),
};
let useCase: ProcessCommandUseCase;
beforeEach(() => {
jest.clearAllMocks();
useCase = new ProcessCommandUseCase({
visionService: mockVisionService,
speechSynthesizer: mockSpeechSynthesizer,
repository: mockRepository,
});
});
describe('execute with DESCRIBE intent', () => {
it('should analyze scene and speak description', async () => {
// Arrange
const command = new VoiceCommand({
text: 'iris describe',
confidence: 0.9,
timestamp: Date.now(),
});
command.intent = 'DESCRIBE';
mockVisionService.analyzeScene.mockResolvedValue({
description: 'Veo una persona y una silla',
objects: [{ label: 'person' }, { label: 'chair' }],
});
// Act
const result = await useCase.execute(command);
// Assert
expect(mockVisionService.analyzeScene).toHaveBeenCalledTimes(1);
expect(mockSpeechSynthesizer.speak).toHaveBeenCalledWith(
'Veo una persona y una silla'
);
expect(result.success).toBe(true);
expect(result.description).toBe('Veo una persona y una silla');
});
it('should handle vision service errors', async () => {
// Arrange
const command = new VoiceCommand({
text: 'iris describe',
confidence: 0.9,
timestamp: Date.now(),
});
command.intent = 'DESCRIBE';
mockVisionService.analyzeScene.mockRejectedValue(
new Error('Camera not available')
);
// Act
const result = await useCase.execute(command);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('Camera not available');
expect(mockSpeechSynthesizer.speak).toHaveBeenCalledWith(
expect.stringContaining('error')
);
});
});
describe('execute with REPEAT intent', () => {
it('should repeat last description', async () => {
// Arrange
const command = new VoiceCommand({
text: 'iris repeat',
confidence: 0.9,
timestamp: Date.now(),
});
command.intent = 'REPEAT';
mockRepository.getLastDescription.mockResolvedValue(
'Veo una persona y una silla'
);
// Act
const result = await useCase.execute(command);
// Assert
expect(mockVisionService.analyzeScene).not.toHaveBeenCalled();
expect(mockSpeechSynthesizer.speak).toHaveBeenCalledWith(
'Veo una persona y una silla'
);
});
});
});
Testing XState Machines
// machines/__tests__/voiceMachine.test.ts
import { createActor, waitFor } from 'xstate';
import { voiceMachine } from '../voiceMachine';
describe('voiceMachine', () => {
describe('initial state', () => {
it('should start in idle state', () => {
const actor = createActor(voiceMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('idle');
expect(actor.getSnapshot().context.error).toBeNull();
});
});
describe('START event', () => {
it('should transition from idle to listening', () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
expect(actor.getSnapshot().value).toBe('listening');
});
});
describe('VOICE_DETECTED event', () => {
it('should update transcript in context', () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
actor.send({
type: 'VOICE_DETECTED',
transcript: 'iris describe',
confidence: 0.9,
});
expect(actor.getSnapshot().context.transcript).toBe('iris describe');
});
it('should ignore low confidence transcripts', () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
actor.send({
type: 'VOICE_DETECTED',
transcript: 'iris describe',
confidence: 0.3, // Below threshold
});
// Should stay in listening, not process
expect(actor.getSnapshot().value).toBe('listening');
});
it('should transition to processing when wake word detected', () => {
const actor = createActor(
voiceMachine.provide({
actors: {
whisperListener: () => () => {},
commandProcessor: async () => ({ description: 'test' }),
},
})
);
actor.start();
actor.send({ type: 'START' });
actor.send({
type: 'VOICE_DETECTED',
transcript: 'iris describe what you see',
confidence: 0.9,
});
// Should have parsed command
expect(actor.getSnapshot().context.command).not.toBeNull();
});
});
describe('error handling', () => {
it('should transition to error state on ERROR event', () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
actor.send({ type: 'ERROR', error: 'Microphone access denied' });
expect(actor.getSnapshot().value).toBe('error');
expect(actor.getSnapshot().context.error).toBe('Microphone access denied');
});
it('should auto-retry up to 3 times', async () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
actor.send({ type: 'ERROR', error: 'Temporary error' });
// First retry
actor.send({ type: 'RETRY' });
expect(actor.getSnapshot().context.retryCount).toBe(1);
// Second retry
actor.send({ type: 'ERROR', error: 'Temporary error' });
actor.send({ type: 'RETRY' });
expect(actor.getSnapshot().context.retryCount).toBe(2);
});
});
describe('STOP event', () => {
it('should return to idle from any state', () => {
const actor = createActor(voiceMachine);
actor.start();
actor.send({ type: 'START' });
expect(actor.getSnapshot().value).toBe('listening');
actor.send({ type: 'STOP' });
expect(actor.getSnapshot().value).toBe('idle');
});
});
});
Testing React Native Components
// presentation/components/__tests__/VoiceCommandPanel.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { VoiceCommandPanel } from '../VoiceCommandPanel';
// Mock the hook
jest.mock('../../hooks/useVoiceCommands', () => ({
useVoiceCommands: jest.fn(),
}));
import { useVoiceCommands } from '../../hooks/useVoiceCommands';
const mockUseVoiceCommands = useVoiceCommands as jest.MockedFunction<
typeof useVoiceCommands
>;
describe('VoiceCommandPanel', () => {
const defaultMockReturn = {
isListening: false,
isProcessing: false,
isSpeaking: false,
hasError: false,
transcript: '',
lastDescription: null,
error: null,
start: jest.fn(),
stop: jest.fn(),
retry: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockUseVoiceCommands.mockReturnValue(defaultMockReturn);
});
it('should render activate button when idle', () => {
render(<VoiceCommandPanel />);
expect(screen.getByRole('button', { name: /activar/i })).toBeOnTheScreen();
});
it('should call start when activate button pressed', () => {
const start = jest.fn();
mockUseVoiceCommands.mockReturnValue({
...defaultMockReturn,
start,
});
render(<VoiceCommandPanel />);
fireEvent.press(screen.getByRole('button', { name: /activar/i }));
expect(start).toHaveBeenCalledTimes(1);
});
it('should show listening status when active', () => {
mockUseVoiceCommands.mockReturnValue({
...defaultMockReturn,
isListening: true,
});
render(<VoiceCommandPanel />);
expect(screen.getByText(/di "iris" para comenzar/i)).toBeOnTheScreen();
expect(screen.getByRole('button', { name: /detener/i })).toBeOnTheScreen();
});
it('should display transcript while listening', () => {
mockUseVoiceCommands.mockReturnValue({
...defaultMockReturn,
isListening: true,
transcript: 'iris describe',
});
render(<VoiceCommandPanel />);
expect(screen.getByText('iris describe')).toBeOnTheScreen();
});
it('should show error message and retry button on error', () => {
const retry = jest.fn();
mockUseVoiceCommands.mockReturnValue({
...defaultMockReturn,
hasError: true,
error: 'Microphone access denied',
retry,
});
render(<VoiceCommandPanel />);
expect(screen.getByText('Microphone access denied')).toBeOnTheScreen();
expect(screen.getByRole('button', { name: /reintentar/i })).toBeOnTheScreen();
fireEvent.press(screen.getByRole('button', { name: /reintentar/i }));
expect(retry).toHaveBeenCalledTimes(1);
});
it('should display description when speaking', () => {
mockUseVoiceCommands.mockReturnValue({
...defaultMockReturn,
isSpeaking: true,
lastDescription: 'Veo una persona y una silla',
});
render(<VoiceCommandPanel />);
expect(screen.getByText('Veo una persona y una silla')).toBeOnTheScreen();
});
});
Testing Custom Hooks
// presentation/hooks/__tests__/useVoiceCommands.test.ts
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useVoiceCommands } from '../useVoiceCommands';
// Mock XState
jest.mock('xstate', () => ({
...jest.requireActual('xstate'),
createActor: jest.fn(),
}));
describe('useVoiceCommands', () => {
it('should return initial state', () => {
const { result } = renderHook(() => useVoiceCommands());
expect(result.current.isListening).toBe(false);
expect(result.current.isProcessing).toBe(false);
expect(result.current.transcript).toBe('');
});
it('should auto-start when autoStart option is true', () => {
const { result } = renderHook(() =>
useVoiceCommands({ autoStart: true })
);
// Machine should have received START event
expect(result.current.isListening).toBe(true);
});
it('should call onCommand callback when command processed', async () => {
const onCommand = jest.fn();
const { result } = renderHook(() =>
useVoiceCommands({ onCommand })
);
act(() => {
result.current.start();
});
// Simulate voice detection
// ... trigger state change
await waitFor(() => {
expect(onCommand).toHaveBeenCalled();
});
});
it('should call onError callback on error', async () => {
const onError = jest.fn();
const { result } = renderHook(() =>
useVoiceCommands({ onError })
);
// Simulate error
// ...
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(expect.any(String));
});
});
});
Mocking Native Modules
// __mocks__/expo-speech.ts
export const speak = jest.fn().mockImplementation((text, options) => {
// Simulate async completion
setTimeout(() => {
options?.onDone?.();
}, 100);
});
export const stop = jest.fn();
export const isSpeakingAsync = jest.fn().mockResolvedValue(false);
// __mocks__/expo-haptics.ts
export const impactAsync = jest.fn().mockResolvedValue(undefined);
export const notificationAsync = jest.fn().mockResolvedValue(undefined);
export const ImpactFeedbackStyle = {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
};
export const NotificationFeedbackType = {
Success: 'success',
Warning: 'warning',
Error: 'error',
};
// jest.setup.js
jest.mock('expo-speech');
jest.mock('expo-haptics');
jest.mock('expo-camera', () => ({
Camera: {
useCameraPermissions: () => [{ granted: true }, jest.fn()],
},
CameraView: 'CameraView',
}));
Test Patterns
Arrange-Act-Assert (AAA)
it('should do something', () => {
// Arrange - setup test data and mocks
const input = { text: 'iris describe', confidence: 0.9 };
// Act - execute the code under test
const result = parseCommand(input);
// Assert - verify the outcome
expect(result.intent).toBe('DESCRIBE');
});
Given-When-Then (BDD Style)
describe('VoiceCommand', () => {
describe('given a high confidence transcript', () => {
describe('when checking validity', () => {
it('then should return true', () => {
const command = new VoiceCommand({ confidence: 0.9, ... });
expect(command.isValid()).toBe(true);
});
});
});
});
Commands
# Run all tests
bun test
# Run tests in watch mode
bun test --watch
# Run specific test file
bun test VoiceCommand.test.ts
# Run tests with coverage
bun test --coverage
# Run tests matching pattern
bun test --testNamePattern="should detect wake word"
# Update snapshots
bun test --updateSnapshot
Best Practices
| Do | Don't |
|---|---|
| Write test FIRST (TDD) | Write tests after implementation |
| Test behavior, not implementation | Test internal details |
| One assertion focus per test | Multiple unrelated assertions |
| Use descriptive test names | Use generic names like "test1" |
| Mock at boundaries (ports) | Mock everything |
| Test edge cases | Only test happy path |
| Keep tests fast (<100ms each) | Slow tests with real delays |
| Use factories for test data | Duplicate setup in every test |
Coverage Targets
| Layer | Target | Rationale |
|---|---|---|
| Domain entities | 100% | Core business logic |
| Use cases | 90%+ | Application logic |
| State machines | 90%+ | All transitions |
| Hooks | 80%+ | Integration points |
| Components | 70%+ | UI behavior |
| Infrastructure | 60%+ | Adapters (mocked in other tests) |
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-26
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!