react-native-sop-docs/06-testing-quality-assurance/unit-testing.md

437 lines
11 KiB
Markdown

# Unit Testing Setup
## Installation
```bash
yarn add -D jest @testing-library/react-native @testing-library/jest-native
```
## Jest Configuration
**jest.config.js:**
```javascript
module.exports = {
preset: 'react-native',
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-navigation)/)',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
```
## Test Setup
```typescript
// src/__tests__/setup.ts
import 'react-native-gesture-handler/jestSetup';
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
Reanimated.default.call = () => {};
return Reanimated;
});
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
// Mock react-native-config
jest.mock('react-native-config', () => ({
API_BASE_URL: 'https://test-api.example.com',
APP_ENV: 'test',
}));
```
## Component Testing
```typescript
// src/components/Button/__tests__/Button.test.tsx
import React from 'react';
import {render, fireEvent} from '@testing-library/react-native';
import {Button} from '../Button';
describe('Button Component', () => {
it('renders correctly', () => {
const {getByText} = render(
<Button title="Test Button" onPress={() => {}} />
);
expect(getByText('Test Button')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const mockOnPress = jest.fn();
const {getByText} = render(
<Button title="Test Button" onPress={mockOnPress} />
);
fireEvent.press(getByText('Test Button'));
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
it('applies correct styles for different variants', () => {
const {getByText, rerender} = render(
<Button title="Primary" onPress={() => {}} variant="primary" />
);
const button = getByText('Primary').parent;
expect(button).toHaveStyle({backgroundColor: '#007AFF'});
rerender(
<Button title="Secondary" onPress={() => {}} variant="secondary" />
);
const secondaryButton = getByText('Secondary').parent;
expect(secondaryButton).toHaveStyle({backgroundColor: '#6C757D'});
});
it('is disabled when disabled prop is true', () => {
const mockOnPress = jest.fn();
const {getByText} = render(
<Button title="Disabled" onPress={mockOnPress} disabled />
);
const button = getByText('Disabled').parent;
expect(button).toHaveStyle({opacity: 0.5});
fireEvent.press(getByText('Disabled'));
expect(mockOnPress).not.toHaveBeenCalled();
});
});
```
## Screen Testing
```typescript
// src/screens/Login/__tests__/LoginScreen.test.tsx
import React from 'react';
import {render, fireEvent, waitFor} from '@testing-library/react-native';
import {Provider} from 'react-redux';
import {configureStore} from '@reduxjs/toolkit';
import LoginScreen from '../LoginScreen';
import authSlice from '@redux/slices/authSlice';
const createMockStore = (initialState = {}) => {
return configureStore({
reducer: {
auth: authSlice,
},
preloadedState: initialState,
});
};
const renderWithProvider = (component: React.ReactElement, store = createMockStore()) => {
return render(
<Provider store={store}>
{component}
</Provider>
);
};
describe('LoginScreen', () => {
it('renders login form correctly', () => {
const {getByPlaceholderText, getByText} = renderWithProvider(
<LoginScreen navigation={{} as any} route={{} as any} />
);
expect(getByPlaceholderText('Email')).toBeTruthy();
expect(getByPlaceholderText('Password')).toBeTruthy();
expect(getByText('Login')).toBeTruthy();
});
it('shows validation errors for empty fields', async () => {
const {getByText, getByTestId} = renderWithProvider(
<LoginScreen navigation={{} as any} route={{} as any} />
);
fireEvent.press(getByText('Login'));
await waitFor(() => {
expect(getByText('Email is required')).toBeTruthy();
expect(getByText('Password is required')).toBeTruthy();
});
});
it('calls login API with correct credentials', async () => {
const mockLogin = jest.fn();
const {getByPlaceholderText, getByText} = renderWithProvider(
<LoginScreen navigation={{} as any} route={{} as any} />
);
fireEvent.changeText(getByPlaceholderText('Email'), 'test@example.com');
fireEvent.changeText(getByPlaceholderText('Password'), 'password123');
fireEvent.press(getByText('Login'));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
});
```
## Redux Testing
```typescript
// src/redux/slices/__tests__/authSlice.test.ts
import authSlice, {
loginStart,
loginSuccess,
loginFailure,
logout,
updateUser,
} from '../authSlice';
describe('authSlice', () => {
const initialState = {
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
loading: false,
error: null,
};
it('should return the initial state', () => {
expect(authSlice(undefined, {type: 'unknown'})).toEqual(initialState);
});
it('should handle loginStart', () => {
const actual = authSlice(initialState, loginStart());
expect(actual.loading).toBe(true);
expect(actual.error).toBe(null);
});
it('should handle loginSuccess', () => {
const payload = {
user: {
id: '1',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
},
token: 'access-token',
refreshToken: 'refresh-token',
};
const actual = authSlice(initialState, loginSuccess(payload));
expect(actual.loading).toBe(false);
expect(actual.isAuthenticated).toBe(true);
expect(actual.user).toEqual(payload.user);
expect(actual.token).toBe(payload.token);
expect(actual.refreshToken).toBe(payload.refreshToken);
});
it('should handle loginFailure', () => {
const errorMessage = 'Invalid credentials';
const actual = authSlice(initialState, loginFailure(errorMessage));
expect(actual.loading).toBe(false);
expect(actual.isAuthenticated).toBe(false);
expect(actual.error).toBe(errorMessage);
});
it('should handle logout', () => {
const loggedInState = {
...initialState,
isAuthenticated: true,
user: {id: '1', email: 'test@example.com', firstName: 'John', lastName: 'Doe'},
token: 'token',
};
const actual = authSlice(loggedInState, logout());
expect(actual).toEqual(initialState);
});
});
```
## API Testing
```typescript
// src/services/__tests__/authAPI.test.ts
import {authAPI} from '../authAPI';
import {store} from '@redux/store';
describe('authAPI', () => {
beforeEach(() => {
// Reset store state
store.dispatch({type: 'RESET'});
});
it('should login successfully', async () => {
const credentials = {
email: 'test@example.com',
password: 'password123',
};
const mockResponse = {
user: {
id: '1',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
},
token: 'access-token',
refreshToken: 'refresh-token',
};
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockResponse),
})
) as jest.Mock;
const result = await store.dispatch(
authAPI.endpoints.login.initiate(credentials)
);
expect(result.data).toEqual(mockResponse);
});
it('should handle login error', async () => {
const credentials = {
email: 'test@example.com',
password: 'wrongpassword',
};
global.fetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({message: 'Invalid credentials'}),
})
) as jest.Mock;
const result = await store.dispatch(
authAPI.endpoints.login.initiate(credentials)
);
expect(result.error).toBeDefined();
});
});
```
## Custom Hooks Testing
```typescript
// src/hooks/__tests__/useAPI.test.ts
import {renderHook, act} from '@testing-library/react-hooks';
import {useAPI} from '../useAPI';
describe('useAPI', () => {
it('should initialize with correct default state', () => {
const {result} = renderHook(() => useAPI());
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle successful API call', async () => {
const {result} = renderHook(() => useAPI());
const mockData = {id: 1, name: 'Test'};
const mockApiCall = jest.fn(() => Promise.resolve(mockData));
await act(async () => {
await result.current.execute(mockApiCall);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle API call error', async () => {
const {result} = renderHook(() => useAPI());
const mockError = new Error('API Error');
const mockApiCall = jest.fn(() => Promise.reject(mockError));
await act(async () => {
try {
await result.current.execute(mockApiCall);
} catch (error) {
// Expected to throw
}
});
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeDefined();
});
});
```
## Test Utilities
```typescript
// src/__tests__/utils/testUtils.tsx
import React from 'react';
import {render, RenderOptions} from '@testing-library/react-native';
import {Provider} from 'react-redux';
import {NavigationContainer} from '@react-navigation/native';
import {configureStore} from '@reduxjs/toolkit';
import authSlice from '@redux/slices/authSlice';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: any;
store?: any;
}
const createTestStore = (preloadedState = {}) => {
return configureStore({
reducer: {
auth: authSlice,
},
preloadedState,
});
};
const AllTheProviders: React.FC<{children: React.ReactNode; store: any}> = ({
children,
store,
}) => {
return (
<Provider store={store}>
<NavigationContainer>
{children}
</NavigationContainer>
</Provider>
);
};
const customRender = (
ui: React.ReactElement,
{
preloadedState = {},
store = createTestStore(preloadedState),
...renderOptions
}: CustomRenderOptions = {}
) => {
const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => (
<AllTheProviders store={store}>{children}</AllTheProviders>
);
return render(ui, {wrapper: Wrapper, ...renderOptions});
};
export * from '@testing-library/react-native';
export {customRender as render};
```