437 lines
11 KiB
Markdown
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};
|
|
``` |