Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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-nitro-modules in Jest environment
module.exports = {
NitroModules: {
createHybridObject: jest.fn(() => {
// Return a mock object that won't be used since MMKV has its own mock
return {};
}),
},
};
28 changes: 28 additions & 0 deletions __mocks__/react-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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();

// __tests__/jest.setup.ts

jest.mock('@react-navigation/native', () => {
//const actualNav = jest.requireActual('@react-navigation/native');
return {
//...actualNav,
useFocusEffect: jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
setOptions: jest.fn(),
}),
useRoute: () => ({
params: {},
}),
};
});
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 },
);
};
Comment on lines +29 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

route leaks into RNTL render options.

After destructuring route from options, the entire options object (still containing route) is spread into render(…, { wrapper, ...options }). This passes the non-standard route key to @testing-library/react-native's render, which may cause warnings or be silently ignored.

Use rest destructuring to separate route from the render-compatible options:

Proposed fix
 const renderNovel = (
   ui: React.ReactElement,
   options?: {
     route?: NovelScreenProps['route'] | ChapterScreenProps['route'];
+  } & Record<string, unknown>,
-  },
 ) => {
-  const { route } = options || {};
+  const { route, ...restOptions } = options || {};
   return render(
     <NovelContextProvider
       route={route as NovelScreenProps['route'] | ChapterScreenProps['route']}
     >
       {ui}
     </NovelContextProvider>,
-    { wrapper: AllTheProviders, ...options },
+    { wrapper: AllTheProviders, ...restOptions },
   );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 },
);
};
const renderNovel = (
ui: React.ReactElement,
options?: {
route?: NovelScreenProps['route'] | ChapterScreenProps['route'];
} & Record<string, unknown>,
) => {
const { route, ...restOptions } = options || {};
return render(
<NovelContextProvider
route={route as NovelScreenProps['route'] | ChapterScreenProps['route']}
>
{ui}
</NovelContextProvider>,
{ wrapper: AllTheProviders, ...restOptions },
);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__tests-modules__/test-utils.tsx` around lines 29 - 44, The renderNovel
helper leaks the non-standard route prop into RNTL's render options; change the
options destructuring to separate route from the other render options (e.g.,
const { route, ...renderOptions } = options || {}), pass route into
NovelContextProvider (route as NovelScreenProps['route'] |
ChapterScreenProps['route']) and spread only renderOptions into render (i.e., {
wrapper: AllTheProviders, ...renderOptions }) so route is not forwarded to
`@testing-library/react-native`'s render.


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