-
Notifications
You must be signed in to change notification settings - Fork 0
Better testing #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Better testing #25
Changes from 10 commits
7396eee
ec37a5c
5932d13
e85101c
066cad3
22c8d54
408ae9c
6228f75
7f13f68
5c28a29
a1068f0
75af1e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 | ||
| ``` |
| 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(), | ||
| })); |
| 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'); |
| 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(''), | ||
| }, | ||
| })); |
| 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 {}; | ||
| }), | ||
| }, | ||
| }; | ||
| 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: {}, | ||
| }), | ||
| }; | ||
| }); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After destructuring Use rest destructuring to separate 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export * from '@testing-library/react-native'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export { customRender as render, renderNovel, AllTheProviders }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; |
Uh oh!
There was an error while loading. Please reload this page.