Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
module.exports = {
root: true,
plugins: ['jest'],
extends: ['@react-native', 'plugin:jest/recommended'],

extends: ['@react-native'],
overrides: [
{
files: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'],
// Test files only
plugins: ['jest'],
files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
extends: ['plugin:testing-library/react', 'plugin:jest/recommended'],
},
{
files: ['*.js', '*.jsx', '*.ts', '*.tsx'],
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Testing
on:
push:
branches:
- master
pull_request:
branches:
- master

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Get pnpm Store Directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Run Tests
run: pnpm run test
80 changes: 80 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Testing Guide for LNReader

This guide explains how to write tests in this React Native project using Jest and React Testing Library.


## Existing Mocks

### Global Mocks

The project has global mocks configured in Jest. These are automatically applied:

- `__mocks__/` - Global mocks for native modules (react-native-mmkv, react-navigation, all database queries, etc.)
- `src/hooks/__mocks__/index.ts` - Hook-specific mocks (showToast, getString, parseChapterNumber, etc.)
- `src/hooks/__tests__/mocks.ts` - Extended mocks for persisted hooks

### Using @test-utils

There's a custom render wrapper at `__tests-modules__/test-utils.tsx` with:

- `render` - wraps with GestureHandlerRootView, SafeAreaProvider, PaperProvider, etc.
- `renderNovel` - includes NovelContextProvider
- `AllTheProviders` - the full provider wrapper

Usage:

```typescript
import { render, renderNovel } from '@test-utils';
```

## Common Issues

### 1. ESM Modules Not Transforming

If you see `Cannot use import statement outside a module`, you need to add mocks for the module:

```typescript
jest.mock('@hooks/persisted/usePlugins');
// Add more specific mocks as needed
```

### 2. Mock Functions Not Working

If `mockReturnValue` throws "not a function", create mock functions at module level:

```typescript
// CORRECT: Module-level mock functions
const mockUseTheme = jest.fn();
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockUseTheme(),
}));

// INCORRECT: Trying to use jest.Mock type casting
// (useTheme as jest.Mock).mockReturnValue(...) // This fails!
```

### 3. Test Isolation

Tests must mock at module level, not in `beforeEach`:

```typescript
// CORRECT
const mockFn = jest.fn();
jest.mock('module', () => ({ useHook: () => mockFn() }));

// INCORRECT - mocks get reset between tests
jest.mock('module');
beforeEach(() => {
// This doesn't work properly
});
```

## Running Tests

```bash
pnpm test # Run all tests
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage
pnpm test:rn # React Native only
pnpm test:db # Database only
```
66 changes: 66 additions & 0 deletions __mocks__/database.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
jest.mock('@database/queries/NovelQueries', () => ({
getNovelByPath: jest.fn(),
deleteCachedNovels: jest.fn(),
getCachedNovels: jest.fn(),
insertNovelAndChapters: jest.fn(),
}));

jest.mock('@database/queries/CategoryQueries', () => ({
getCategoriesFromDb: jest.fn(),
getCategoriesWithCount: jest.fn(),
createCategory: jest.fn(),
deleteCategoryById: jest.fn(),
updateCategory: jest.fn(),
isCategoryNameDuplicate: jest.fn(),
updateCategoryOrderInDb: jest.fn(),
getAllNovelCategories: jest.fn(),
_restoreCategory: jest.fn(),
}));

jest.mock('@database/queries/ChapterQueries', () => ({
bookmarkChapter: jest.fn(),
markChapterRead: jest.fn(),
markChaptersRead: jest.fn(),
markPreviuschaptersRead: jest.fn(),
markPreviousChaptersUnread: jest.fn(),
markChaptersUnread: jest.fn(),
deleteChapter: jest.fn(),
deleteChapters: jest.fn(),
getPageChapters: jest.fn(),
insertChapters: jest.fn(),
getCustomPages: jest.fn(),
getChapterCount: jest.fn(),
getPageChaptersBatched: jest.fn(),
getFirstUnreadChapter: jest.fn(),
updateChapterProgress: jest.fn(),
}));

jest.mock('@database/queries/HistoryQueries', () => ({
getHistoryFromDb: jest.fn(),
insertHistory: jest.fn(),
deleteChapterHistory: jest.fn(),
deleteAllHistory: jest.fn(),
}));

jest.mock('@database/queries/LibraryQueries', () => ({
getLibraryNovelsFromDb: jest.fn(),
getLibraryWithCategory: jest.fn(),
}));

jest.mock('@database/queries/RepositoryQueries', () => ({
getRepositoriesFromDb: jest.fn(),
isRepoUrlDuplicated: jest.fn(),
createRepository: jest.fn(),
deleteRepositoryById: jest.fn(),
updateRepository: jest.fn(),
}));

jest.mock('@database/queries/StatsQueries', () => ({
getLibraryStatsFromDb: jest.fn(),
getChaptersTotalCountFromDb: jest.fn(),
getChaptersReadCountFromDb: jest.fn(),
getChaptersUnreadCountFromDb: jest.fn(),
getChaptersDownloadedCountFromDb: jest.fn(),
getNovelGenresFromDb: jest.fn(),
getNovelStatusFromDb: jest.fn(),
}));
4 changes: 4 additions & 0 deletions __mocks__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
require('./nativeModules');
require('./react-native-mmkv');
require('./database');
require('./react-navigation');
67 changes: 67 additions & 0 deletions __mocks__/nativeModules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// require('react-native-gesture-handler/jestSetup');
// require('react-native-reanimated').setUpTests();

jest.mock('@specs/NativeFile', () => ({
__esModule: true,
default: {
writeFile: jest.fn(),
readFile: jest.fn(() => ''),
copyFile: jest.fn(),
moveFile: jest.fn(),
exists: jest.fn(() => true),
mkdir: jest.fn(),
unlink: jest.fn(),
readDir: jest.fn(() => []),
downloadFile: jest.fn().mockResolvedValue(),
getConstants: jest.fn(() => ({
ExternalDirectoryPath: '/mock/external',
ExternalCachesDirectoryPath: '/mock/caches',
})),
},
}));

jest.mock('@specs/NativeEpub', () => ({
__esModule: true,
default: {
parseNovelAndChapters: jest.fn(() => ({
name: 'Mock Novel',
cover: null,
summary: null,
author: null,
artist: null,
chapters: [],
cssPaths: [],
imagePaths: [],
})),
},
}));

jest.mock('@specs/NativeTTSMediaControl', () => ({
__esModule: true,
default: {
showMediaNotification: jest.fn(),
updatePlaybackState: jest.fn(),
updateProgress: jest.fn(),
dismiss: jest.fn(),
addListener: jest.fn(),
removeListeners: jest.fn(),
},
}));

jest.mock('@specs/NativeVolumeButtonListener', () => ({
__esModule: true,
default: {
addListener: jest.fn(),
removeListeners: jest.fn(),
},
}));

jest.mock('@specs/NativeZipArchive', () => ({
__esModule: true,
default: {
zip: jest.fn().mockResolvedValue(),
unzip: jest.fn().mockResolvedValue(),
remoteUnzip: jest.fn().mockResolvedValue(),
remoteZip: jest.fn().mockResolvedValue(''),
},
}));
9 changes: 9 additions & 0 deletions __mocks__/react-native-mmkv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Mock for react-native-mmkv (v3 uses NitroModules under the hood)
module.exports = {
NitroModules: {
createHybridObject: jest.fn(() => {
// Return a mock object that won't be used since MMKV has its own mock
return {};
}),
},
};
29 changes: 29 additions & 0 deletions __mocks__/react-navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();

jest.mock('react-native-worklets', () =>
require('react-native-worklets/src/mock'),
);

// Include this line for mocking react-native-gesture-handler
require('react-native-gesture-handler/jestSetup');

// Include this section for mocking react-native-reanimated
const { setUpTests } = require('react-native-reanimated');

setUpTests();

jest.mock('@react-navigation/native', () => {
return {
useFocusEffect: jest.fn(),
useNavigation: () => ({
navigate: mockNavigate,
setOptions: mockSetOptions,
}),
useRoute: () => ({
params: {},
}),
};
});

module.exports = { mockNavigate, mockSetOptions };
48 changes: 48 additions & 0 deletions __tests-modules__/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render } from '@testing-library/react-native';
import React from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider as PaperProvider } from 'react-native-paper';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';

import AppErrorBoundary from '@components/AppErrorBoundary/AppErrorBoundary';
import { NovelContextProvider } from '@screens/novel/NovelContext';
import { NovelScreenProps, ChapterScreenProps } from '@navigators/types';

const AllTheProviders = ({ children }: { children: React.ReactElement }) => {
return (
<GestureHandlerRootView>
<SafeAreaProvider>
<PaperProvider>
<BottomSheetModalProvider>
<AppErrorBoundary>{children}</AppErrorBoundary>
</BottomSheetModalProvider>
</PaperProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
};

const customRender = (ui: React.ReactElement, options?: object) =>
render(ui, { wrapper: AllTheProviders, ...options });

const renderNovel = (
ui: React.ReactElement,
options?: {
route?: NovelScreenProps['route'] | ChapterScreenProps['route'];
},
) => {
const { route } = options || {};
return render(
<NovelContextProvider
route={route as NovelScreenProps['route'] | ChapterScreenProps['route']}
>
{ui}
</NovelContextProvider>,
{ wrapper: AllTheProviders, ...options },
);
};

export * from '@testing-library/react-native';

export { customRender as render, renderNovel, AllTheProviders };
3 changes: 3 additions & 0 deletions __tests__/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
process.env.EXPO_OS = 'android';

global.IS_REACT_ACT_ENVIRONMENT = true;
1 change: 1 addition & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = function (api) {
'@api': './src/api',
'@type': './src/type',
'@specs': './specs',
'@test-utils': './__tests-modules__/test-utils',
'react-native-vector-icons/MaterialCommunityIcons':
'@react-native-vector-icons/material-design-icons',
},
Expand Down
Loading
Loading