| name | react-testing-conventions |
| description | Testing conventions for React Native with TypeScript. Use this skill when writing tests, reviewing test code, or setting up test infrastructure. Covers unit tests, integration tests, component tests, test doubles (dummy, stub, spy, mock), builder pattern for fixtures, and the testing pyramid. |
Testing Conventions
Testing conventions for React Native applications with TypeScript.
Stack: Jest • React Native Testing Library • TypeScript
Core Principles
Testing Pyramid
Respect the testing pyramid — more unit tests, fewer integration tests, even fewer E2E tests:
/\
/ \ E2E (Maestro)
/----\ Few, slow, expensive
/ \
/--------\ Integration
/ \ Some, medium speed
/------------\
/ \ Unit (*.test.ts)
/________________\ Many, fast, cheap
FIRST Principles
Tests must be:
| Principle | Description |
|---|
| Fast | Tests run quickly — milliseconds, not seconds |
| Independent | Tests don't depend on each other, can run in any order |
| Repeatable | Same result every time, no flakiness |
| Self-validating | Pass or fail, no manual inspection needed |
| Timely | Written at the same time as the code (or before with TDD) |
File Organization
Naming
| Type | Extension | Example |
|---|
| Unit test | .test.ts | Login.usecase.test.ts |
| E2E test (Maestro) | .yaml | login-flow.yaml |
Colocation (Unit & Integration)
Test files live next to the files they test:
modules/authentication/core/usecases/
├── Login.usecase.ts
└── Login.usecase.test.ts
modules/authentication/ui/viewModels/
├── useLogin.viewModel.tsx
└── useLogin.viewModel.test.ts
modules/authentication/infrastructure/adapters/
├── AuthApi.adapter.ts
└── AuthApi.adapter.test.ts
E2E Tests (Maestro)
E2E tests live in a dedicated folder at project root:
tests/
├── flows/
│ ├── authentication/
│ │ ├── login-flow.yaml
│ │ └── logout-flow.yaml
│ └── events/
│ ├── create-event-flow.yaml
│ └── delete-event-flow.yaml
└── utils/
└── common-steps.yaml
Test Anatomy
AAA Pattern
Every test follows Arrange, Act, Assert:
it("should return user when credentials are valid", async () => {
const authRepository = new AuthRepositoryStub();
const useCase = new LoginUseCase(authRepository);
const credentials = { email: "test@example.com", password: "password123" };
const result = await useCase.execute(credentials);
expect(result.success).toBe(true);
expect(result.data.email).toBe("test@example.com");
});
Test Naming
Pattern: should [expected behavior] when [condition]
it("should return failure when email is invalid", ...)
it("should disable button when form is incomplete", ...)
it("should invalidate queries when mutation succeeds", ...)
it("test login", ...)
it("works", ...)
it("handles error", ...)
One logical assertion per test
it("should return failure when password is too short", async () => {
const result = await useCase.execute({ email: "a@b.com", password: "123" });
expect(result.success).toBe(false);
expect(result.error.type).toBe("VALIDATION_ERROR");
});
it("should validate all fields", async () => {
const result1 = await useCase.execute({ email: "", password: "valid123" });
expect(result1.success).toBe(false);
const result2 = await useCase.execute({ email: "a@b.com", password: "" });
expect(result2.success).toBe(false);
const result3 = await useCase.execute({
email: "a@b.com",
password: "valid123",
});
expect(result3.success).toBe(true);
});
Test Doubles
When to use what
| Test Double | Use Case | Layer |
|---|
| Dummy | Placeholder, never actually used | Core, UI |
| Stub | Returns predefined data | Core, UI |
| Spy | Records calls, verifies interactions | Infrastructure |
| Mock | Spy + predefined behavior | Infrastructure |
Rule: Stub the Core, Mock the Infrastructure
const authRepository = new AuthRepositoryStub();
const useCase = new LoginUseCase(authRepository);
jest.spyOn(global, "fetch").mockResolvedValue(mockResponse);
Dummy
Never actually used, just satisfies the type:
class LoggerDummy implements Logger {
log(_message: string): void {
}
}
const useCase = new SomeUseCase(realDependency, new LoggerDummy());
Stub
Returns predefined data:
class AuthRepositoryStub implements AuthRepository {
async login(_params: LoginParams): Promise<Result<User, AuthError>> {
return ok({
id: "user-1",
email: "test@example.com",
displayName: "Test User",
});
}
async logout(): Promise<Result<void, AuthError>> {
return ok(undefined);
}
}
class AuthRepositoryStub implements AuthRepository {
private loginResult: Result<User, AuthError> = ok(userBuilder().build());
withLoginSuccess(user: User): this {
this.loginResult = ok(user);
return this;
}
withLoginFailure(error: AuthError): this {
this.loginResult = fail(error);
return this;
}
async login(_params: LoginParams): Promise<Result<User, AuthError>> {
return this.loginResult;
}
}
const stub = new AuthRepositoryStub().withLoginFailure({
type: "INVALID_CREDENTIALS",
});
Spy / Mock (Infrastructure only)
describe("AuthApiAdapter", () => {
it("should call fetch with correct parameters", async () => {
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ id: "1", email: "test@example.com" }),
} as Response);
const adapter = new AuthApiAdapter();
await adapter.login({ email: "test@example.com", password: "password" });
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.example.com/auth/login",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
email: "test@example.com",
password: "password",
}),
})
);
});
});
Builder Pattern
Use builders to create test fixtures (entities, props, etc.):
Entity Builder
import { User } from "./User.entity";
export const userBuilder = () => {
const user: User = {
id: "user-123",
email: "default@example.com",
displayName: "Default User",
createdAt: "2024-01-01T00:00:00Z",
};
const builder = {
id: (id: string) => {
user.id = id;
return builder;
},
email: (email: string) => {
user.email = email;
return builder;
},
displayName: (displayName: string) => {
user.displayName = displayName;
return builder;
},
createdAt: (createdAt: string) => {
user.createdAt = createdAt;
return builder;
},
build: () => user,
};
return builder;
};
const user = userBuilder()
.email("custom@test.com")
.displayName("Custom")
.build();
Props Builder
import { EventCardProps } from "./EventCard";
export const eventCardPropsBuilder = () => {
const props: EventCardProps = {
title: "Default Event",
date: "2024-06-15T10:00:00Z",
attendees: 10,
onPress: jest.fn(),
};
const builder = {
title: (title: string) => {
props.title = title;
return builder;
},
date: (date: string) => {
props.date = date;
return builder;
},
attendees: (attendees: number) => {
props.attendees = attendees;
return builder;
},
onPress: (onPress: VoidFunction) => {
props.onPress = onPress;
return builder;
},
build: () => props,
};
return builder;
};
Builder naming
| Type | File | Function |
|---|
| Entity | User.builder.ts | userBuilder() |
| Props | EventCard.props.builder.ts | eventCardPropsBuilder() |
| Params | LoginParams.builder.ts | loginParamsBuilder() |
Testing Hooks
Custom renderHook
Use the custom renderHook that provides dependencies. See references/test-utils.md for the full implementation.
export function renderHook<Result, Props>(
renderCallback: (props: Props) => Result,
options?: {
initialProps?: Props;
wrapper?: ComponentType<{ children: ReactNode }>;
dependencies?: Partial<Dependencies>;
}
): RenderHookResult<Result, Props>;
Usage
import { act } from "@testing-library/react-native";
import { renderHook } from "@app/react/renderHook";
import { useLoginViewModel } from "./useLogin.viewModel";
describe("useLoginViewModel", () => {
it("should update state to success when login succeeds", async () => {
const authRepositoryStub = new AuthRepositoryStub().withLoginSuccess(
userBuilder().build()
);
const { result } = renderHook(() => useLoginViewModel(), {
dependencies: { authRepository: authRepositoryStub },
});
await act(async () => {
await result.current.handlers.login("test@example.com", "password123");
});
expect(result.current.state.status).toBe("success");
});
});
Testing Components
Custom render
Use the custom render that provides dependencies. See references/test-utils.md for the full implementation.
export function render(
component: ReactElement,
options?: {
wrapper?: ComponentType<{ children: ReactNode }>;
dependencies?: Partial<Dependencies>;
}
): RenderResult;
Usage
import { screen, fireEvent } from "@testing-library/react-native";
import { render } from "@app/react/render";
import { LoginScreen } from "./LoginScreen";
import { TestIDs } from "@/constants/testIDs";
describe("LoginScreen", () => {
it("should disable submit button when form is incomplete", () => {
render(<LoginScreen />);
const submitButton = screen.getByTestId(TestIDs.Login.submitButton);
expect(submitButton.props.accessibilityState.disabled).toBe(true);
});
it("should call login when submit is pressed", async () => {
const authRepositoryStub = new AuthRepositoryStub();
render(<LoginScreen />, {
dependencies: { authRepository: authRepositoryStub },
});
fireEvent.changeText(
screen.getByTestId(TestIDs.Login.emailInput),
"test@example.com"
);
fireEvent.changeText(
screen.getByTestId(TestIDs.Login.passwordInput),
"password123"
);
fireEvent.press(screen.getByTestId(TestIDs.Login.submitButton));
});
});
Query priority
Prefer queries in this order:
getByRole — accessible queries
getByTestId — when role is not enough
getByText — for static text
screen.getByRole("button", { name: "Submit" });
screen.getByTestId(TestIDs.Login.submitButton);
screen.getByText("Submit");
Test Isolation
beforeEach / afterEach
Use beforeEach and afterEach to ensure test isolation and idempotence:
describe("useUserProfile", () => {
let authRepositoryStub: AuthRepositoryStub;
beforeEach(() => {
authRepositoryStub = new AuthRepositoryStub();
});
afterEach(() => {
jest.clearAllMocks();
});
it("should load user", async () => {
});
});
Zustand Store Reset
Zustand stores must be reset between tests to ensure isolation. See references/test-utils.md for the full store template.
Testing with Zustand
Use setState directly to arrange state and reset:
import { renderHook } from "@app/react/renderHook";
import { useProfileViewModel } from "./useProfile.viewModel";
import { createAuthStore } from "../stores/auth.store";
describe("useProfileViewModel", () => {
let authStore: ReturnType<typeof createAuthStore>;
beforeEach(() => {
authStore = createAuthStore();
});
afterEach(() => {
authStore.setState({ user: null, isAuthenticated: false }, true);
});
it("should display user when authenticated", () => {
authStore.setState({
user: userBuilder().displayName("John").build(),
isAuthenticated: true,
});
const { result } = renderHook(() => useProfileViewModel(), {
wrapper: ({ children }) => (
<AuthStoreProvider store={authStore}>{children}</AuthStoreProvider>
),
});
expect(result.current.state.user?.displayName).toBe("John");
});
it("should show empty state when not authenticated", () => {
const { result } = renderHook(() => useProfileViewModel(), {
wrapper: ({ children }) => (
<AuthStoreProvider store={authStore}>{children}</AuthStoreProvider>
),
});
expect(result.current.state.user).toBeNull();
});
});
Anti-patterns
❌ Testing implementation details
expect(component.state.isLoading).toBe(true);
expect(screen.getByTestId("loading-indicator")).toBeTruthy();
❌ Over-mocking
jest.mock("../hooks/useUser");
jest.mock("../hooks/useAuth");
jest.mock("../utils/format");
const authRepository = new AuthRepositoryStub();
❌ Tests without assertions
it("should render", () => {
render(<Component />);
});
it("should render title", () => {
render(<Component />);
expect(screen.getByText("Welcome")).toBeTruthy();
});
❌ Flaky tests
await new Promise((r) => setTimeout(r, 1000));
expect(result).toBe(expected);
await waitFor(() => {
expect(result).toBe(expected);
});
❌ Tests coupled to each other
let user: User;
it("should create user", () => {
user = createUser();
expect(user).toBeDefined();
});
it("should update user", () => {
user.name = "New Name";
expect(user.name).toBe("New Name");
});
it("should create user", () => {
const user = createUser();
expect(user).toBeDefined();
});
it("should update user", () => {
const user = createUser();
user.name = "New Name";
expect(user.name).toBe("New Name");
});
References