Migrate to @testing-library/react-native 14
CI / checks (push) Successful in 2m6s

This commit is contained in:
ponas committed 2026-09-05 19:21:52 +02:00
1 parent 0228507585
commit 0afb278eef
19 files changed
+203 -192

No files matched your search

+6
View File
@@ -29,6 +29,12 @@ jest.mock('@react-native-async-storage/async-storage', () =>
jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js'));
// The preset's RefreshControl mock drops its props, so onRefresh and testID never reach the host tree
jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => {
const React = require('react');
return { __esModule: true, default: (props) => React.createElement('RCTRefreshControl', props) };
});
// Reanimated (even its mock) initializes the native side of worklets at import
// time; substitute the mock that ships with react-native-worklets.
jest.mock('react-native-worklets', () => require('react-native-worklets/src/mock'));
+51 -11
View File
@@ -44,7 +44,7 @@
},
"devDependencies": {
"@babel/core": "^7.24.9",
"@testing-library/react-native": "^13.3.3",
"@testing-library/react-native": "^14.0.1",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.10",
"eslint": "^9.39.5",
@@ -54,7 +54,7 @@
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
"prettier": "^3.9.6",
"react-test-renderer": "19.2.3",
"test-renderer": "1.2",
"typescript": "~6.0.3"
}
},
@@ -4551,25 +4551,25 @@
}
},
"node_modules/@testing-library/react-native": {
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
"integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz",
"integrity": "sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"jest-matcher-utils": "^30.0.5",
"jest-matcher-utils": "^30.4.1",
"picocolors": "^1.1.1",
"pretty-format": "^30.0.5",
"pretty-format": "^30.4.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=18"
"node": "^22.13.0 || >=24"
},
"peerDependencies": {
"jest": ">=29.0.0",
"react": ">=18.2.0",
"react-native": ">=0.71",
"react-test-renderer": ">=18.2.0"
"react": ">=19.0.0",
"react-native": ">=0.78",
"test-renderer": "^1.0.0"
},
"peerDependenciesMeta": {
"jest": {
@@ -4780,6 +4780,16 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-test-renderer": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
@@ -15657,6 +15667,36 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-renderer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/test-renderer/-/test-renderer-1.2.0.tgz",
"integrity": "sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "~0.33.0",
"react-reconciler": "~0.33.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/test-renderer/node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/throat": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
+2 -2
View File
@@ -51,7 +51,7 @@
},
"devDependencies": {
"@babel/core": "^7.24.9",
"@testing-library/react-native": "^13.3.3",
"@testing-library/react-native": "^14.0.1",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.10",
"eslint": "^9.39.5",
@@ -61,7 +61,7 @@
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
"prettier": "^3.9.6",
"react-test-renderer": "19.2.3",
"test-renderer": "~1.2.0",
"typescript": "~6.0.3"
},
"expo": {
+1 -1
View File
@@ -15,7 +15,7 @@ jest.mock('@stripe/stripe-react-native', () => ({
afterEach(() => appQueryClient.clear());
it('renders the whole app up to the navigation tabs', async () => {
const { findByText } = render(<App />);
const { findByText } = await render(<App />);
// A tab label proves the auth bootstrap finished and Navigation mounted
expect(await findByText('Medlemskap')).toBeTruthy();
+20 -13
View File
@@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native';
import React from 'react';
import { AuthProvider, useAuth } from '@/auth/AuthContext';
import * as tokenStorage from '@/auth/tokenStorage';
import { queryKeys } from '@/queries/queryKeys';
const TOKEN_KEY = 'cnapp.authToken';
@@ -13,7 +14,7 @@ const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: tr
// gcTime: Infinity so no GC timers keep Jest alive
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } });
const renderAuth = ({
const renderAuth = async ({
initialToken,
queryClient = createQueryClient(),
}: { initialToken?: string | null; queryClient?: QueryClient } = {}) => {
@@ -22,7 +23,7 @@ const renderAuth = ({
<AuthProvider initialToken={initialToken}>{children}</AuthProvider>
</QueryClientProvider>
);
return { queryClient, ...renderHook(() => useAuth(), { wrapper }) };
return { queryClient, ...(await renderHook(() => useAuth(), { wrapper })) };
};
beforeEach(async () => {
@@ -32,19 +33,25 @@ beforeEach(async () => {
});
it('renders nothing until the stored session is restored', async () => {
await AsyncStorage.setItem(TOKEN_KEY, 'token-123');
// Hold the token read so the closed gate is still observable after the async render
let releaseToken!: (token: string | null) => void;
jest
.spyOn(tokenStorage, 'getStoredToken')
.mockImplementationOnce(() => new Promise((resolve) => (releaseToken = resolve)));
const { result } = renderAuth();
const { result } = await renderAuth();
// The gate: children (and thus the hook) have not rendered yet
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).not.toBeNull());
await act(async () => {
releaseToken('token-123');
});
expect(result.current.token).toBe('token-123');
expect(result.current.isAuthenticated).toBe(true);
});
it('starts logged out when nothing is stored', async () => {
const { result } = renderAuth();
const { result } = await renderAuth();
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current.token).toBeNull();
@@ -57,7 +64,7 @@ it('runs the redux-persist migration and seeds the cached user', async () => {
JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }),
);
const { result, queryClient } = renderAuth();
const { result, queryClient } = await renderAuth();
await waitFor(() => expect(result.current?.token).toBe('9a8b'));
expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen);
@@ -65,9 +72,9 @@ it('runs the redux-persist migration and seeds the cached user', async () => {
});
it('signIn stores the token and flips isAuthenticated', async () => {
const { result } = renderAuth({ initialToken: null });
const { result } = await renderAuth({ initialToken: null });
act(() => result.current.signIn('token-1'));
await act(() => result.current.signIn('token-1'));
expect(result.current.token).toBe('token-1');
expect(result.current.isAuthenticated).toBe(true);
@@ -79,8 +86,8 @@ it('signOut clears the token and drops the cached user', async () => {
queryClient.setQueryData(queryKeys.user, grisen);
await AsyncStorage.setItem(TOKEN_KEY, 'token-1');
const { result } = renderAuth({ initialToken: 'token-1', queryClient });
act(() => result.current.signOut());
const { result } = await renderAuth({ initialToken: 'token-1', queryClient });
await act(() => result.current.signOut());
expect(result.current.token).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
@@ -88,10 +95,10 @@ it('signOut clears the token and drops the cached user', async () => {
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull());
});
it('skips the storage bootstrap when given an initialToken', () => {
it('skips the storage bootstrap when given an initialToken', async () => {
const getItemSpy = jest.spyOn(AsyncStorage, 'getItem');
const { result } = renderAuth({ initialToken: 'token-x' });
const { result } = await renderAuth({ initialToken: 'token-x' });
// Synchronously ready: no gate, no storage reads
expect(result.current.token).toBe('token-x');
+2 -2
View File
@@ -3,8 +3,8 @@ import React from 'react';
import About from '@/modules/about/About';
import { renderWithProviders } from '@/testUtils';
it('renders the about screen', () => {
const { getByText } = renderWithProviders(<About />);
it('renders the about screen', async () => {
const { getByText } = await renderWithProviders(<About />);
expect(getByText('Åpningstider')).toBeTruthy();
expect(getByText('Ta kontakt')).toBeTruthy();
+8 -8
View File
@@ -47,7 +47,7 @@ const event = {
it('formats the next occurrence as a Norwegian date with the venue', async () => {
mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) });
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
});
@@ -59,7 +59,7 @@ it('formats weekday and month abbreviations across the year', async () => {
];
mockFetch.mockResolvedValue({ json: async () => ({ events }) });
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('on. 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy();
expect(await findByText('ma. 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy();
@@ -70,7 +70,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone'
const events = [{ ...event, nextOccurrence: { ...event.nextOccurrence, start: '2026-08-01T17:00:00Z' } }];
mockFetch.mockResolvedValue({ json: async () => ({ events }) });
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
});
@@ -78,7 +78,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone'
it('prefixes the time with Neste: when the event has more occurrences', async () => {
mockFetch.mockResolvedValue({ json: async () => ({ events: [{ ...event, futureOccurrencesCount: 3 }] }) });
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('Neste: lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
expect(await findByText('Se flere tider (2)')).toBeTruthy();
@@ -87,7 +87,7 @@ it('prefixes the time with Neste: when the event has more occurrences', async ()
it('renders events from the API', async () => {
mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) });
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('Lørdagsquiz')).toBeTruthy();
});
@@ -95,7 +95,7 @@ it('renders events from the API', async () => {
it('renders an error state when the API is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('network down'));
const { findByText } = renderWithProviders(<EventList />);
const { findByText } = await renderWithProviders(<EventList />);
expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy();
});
@@ -106,11 +106,11 @@ it('stops the refresh spinner and shows the error when refresh fails', async ()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useEventList(), { wrapper });
const { result } = await renderHook(() => useEventList(), { wrapper });
await waitFor(() => expect(result.current.events).toHaveLength(1));
mockFetch.mockRejectedValue(new Error('network down'));
act(() => result.current.handleRefresh());
await act(() => result.current.handleRefresh());
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.refreshing).toBe(false);
+22 -22
View File
@@ -15,20 +15,20 @@ jest.mock('@react-navigation/native', () => {
return { ...actual, useNavigation: () => ({ goBack: mockGoBack }) };
});
const fillForm = (
const fillForm = async (
getByLabelText: RenderResult['getByLabelText'],
{ username = 'grisen@example.test', password = 'hunter2' } = {},
) => {
fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username);
fireEvent.changeText(getByLabelText('Passord'), password);
await fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username);
await fireEvent.changeText(getByLabelText('Passord'), password);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the login form', () => {
const { getByText } = renderWithProviders(<Login />);
it('renders the login form', async () => {
const { getByText } = await renderWithProviders(<Login />);
expect(getByText('E-post / Brukernavn')).toBeTruthy();
expect(getByText('Passord')).toBeTruthy();
@@ -37,18 +37,18 @@ it('renders the login form', () => {
});
it('moves focus to the password field when the username is submitted from the keyboard', async () => {
const { getByLabelText } = renderWithProviders(<Login />);
const { getByLabelText } = await renderWithProviders(<Login />);
fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing');
await fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing');
// react-hook-form's setFocus defers the focus call to the next tick
await waitFor(() => expect(focusedFieldLabel()).toBe('Passord'));
});
it('opens the forgot password page in the browser', () => {
const { getByText } = renderWithProviders(<Login />);
it('opens the forgot password page in the browser', async () => {
const { getByText } = await renderWithProviders(<Login />);
fireEvent.press(getByText('Glemt passord?'));
await fireEvent.press(getByText('Glemt passord?'));
// Not one of our own domains in the test env, so no UTM tagging (covered in utils.test.ts)
expect(Linking.openURL).toHaveBeenCalledWith('https://dusken.example.test/auth/password_reset/');
@@ -56,10 +56,10 @@ it('opens the forgot password page in the browser', () => {
it('logs in against the API and returns to the previous screen', async () => {
const login = jest.spyOn(api, 'login').mockResolvedValue({ token: 'token-123' });
const { getByText, getByLabelText } = renderWithProviders(<Login />);
const { getByText, getByLabelText } = await renderWithProviders(<Login />);
fillForm(getByLabelText);
fireEvent.press(getByText('Logg inn'));
await fillForm(getByLabelText);
await fireEvent.press(getByText('Logg inn'));
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
expect(login).toHaveBeenCalledWith('grisen@example.test', 'hunter2');
@@ -72,10 +72,10 @@ it('shows the server error when the credentials are rejected', async () => {
400,
);
jest.spyOn(api, 'login').mockRejectedValueOnce(invalidCredentials);
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
const { getByText, getByLabelText, findByText } = await renderWithProviders(<Login />);
fillForm(getByLabelText);
fireEvent.press(getByText('Logg inn'));
await fillForm(getByLabelText);
await fireEvent.press(getByText('Logg inn'));
expect(await findByText('Kan ikke logge inn med angitt brukernavn og passord')).toBeTruthy();
expect(mockGoBack).not.toHaveBeenCalled();
@@ -84,20 +84,20 @@ it('shows the server error when the credentials are rejected', async () => {
it('shows a generic message when the request fails on the network', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(api, 'login').mockRejectedValueOnce(new Error('network down'));
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
const { getByText, getByLabelText, findByText } = await renderWithProviders(<Login />);
fillForm(getByLabelText);
fireEvent.press(getByText('Logg inn'));
await fillForm(getByLabelText);
await fireEvent.press(getByText('Logg inn'));
expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy();
expect(mockGoBack).not.toHaveBeenCalled();
});
it('logs in a dev-fake user without hitting the network', async () => {
const { getByText, getByLabelText } = renderWithProviders(<Login />);
const { getByText, getByLabelText } = await renderWithProviders(<Login />);
fillForm(getByLabelText, { username: 'member', password: 'whatever' });
fireEvent.press(getByText('Logg inn'));
await fillForm(getByLabelText, { username: 'member', password: 'whatever' });
await fireEvent.press(getByText('Logg inn'));
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
expect(global.fetch).not.toHaveBeenCalled();
+3 -1
View File
@@ -44,7 +44,9 @@ const Login = () => {
<Text style={theme.buttonText}>Logg inn</Text>
</Button>
{loginError && isSubmitted && <FormErrors errors={loginError} />}
{isLoggingIn && <ActivityIndicator color={theme.colors.neufOrange} />}
{isLoggingIn && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
)}
<Button
small
onPress={() => {
+11 -12
View File
@@ -1,6 +1,5 @@
import { fireEvent } from '@testing-library/react-native';
import React from 'react';
import { RefreshControl } from 'react-native';
import * as api from '@/api';
import { ApiError } from '@/errors';
@@ -19,7 +18,7 @@ beforeEach(() => {
});
it('shows the membership pitch when logged out', async () => {
const { findByText } = renderWithProviders(<Membership />);
const { findByText } = await renderWithProviders(<Membership />);
expect(await findByText('Bli medlem')).toBeTruthy();
expect(await findByText('Logg inn')).toBeTruthy();
@@ -28,7 +27,7 @@ it('shows the membership pitch when logged out', async () => {
it('lets a logged in non-member pick a membership type and shows its price', async () => {
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
const { findByText, getByText } = renderWithProviders(<Membership />, {
const { findByText, getByText } = await renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
});
@@ -43,15 +42,15 @@ it('lets a logged in non-member pick a membership type and shows its price', asy
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
// Selecting another type updates the price
fireEvent.press(getByText('Student'));
await fireEvent.press(getByText('Student'));
expect(await findByText('Kjøp medlemskap (150 NOK)')).toBeTruthy();
fireEvent.press(getByText('Støtte'));
await fireEvent.press(getByText('Støtte'));
expect(await findByText('Kjøp medlemskap (500 NOK)')).toBeTruthy();
});
it('shows the membership proof when logged in', async () => {
const { findByText } = renderWithProviders(<Membership />, {
const { findByText } = await renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
});
@@ -63,7 +62,7 @@ it('shows the membership proof when logged in', async () => {
it('fetches the user with the stored token when only the token survives a cold start', async () => {
const fetchUserData = jest.spyOn(api, 'fetchUserData').mockResolvedValue(testUser);
const { findByText } = renderWithProviders(<Membership />, {
const { findByText } = await renderWithProviders(<Membership />, {
initialToken: 'test-token',
});
@@ -75,7 +74,7 @@ it('fetches the user with the stored token when only the token survives a cold s
it('refetches and renders updated user data on pull-to-refresh', async () => {
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
const { findByText, UNSAFE_getByType } = renderWithProviders(<Membership />, {
const { findByText, getByTestId } = await renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
});
@@ -83,7 +82,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
// the server now reports an active membership, bought elsewhere
const fetchUserData = jest.spyOn(api, 'fetchUserData').mockResolvedValue(testUser);
fireEvent(UNSAFE_getByType(RefreshControl), 'refresh');
await fireEvent(getByTestId('refresh-control'), 'refresh');
expect(await findByText('MEDLEM')).toBeTruthy();
expect(fetchUserData).toHaveBeenCalledWith('test-token');
@@ -92,7 +91,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
it('signs out when the stored token has been revoked', async () => {
jest.spyOn(api, 'fetchUserData').mockRejectedValueOnce(new ApiError({ detail: 'Ugyldig token.' }, 401));
const { findByText } = renderWithProviders(<Membership />, {
const { findByText } = await renderWithProviders(<Membership />, {
initialToken: 'revoked-token',
});
@@ -100,12 +99,12 @@ it('signs out when the stored token has been revoked', async () => {
});
it('logs out and shows the logged-out membership screen again', async () => {
const { findByText } = renderWithProviders(<Membership />, {
const { findByText } = await renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
});
fireEvent.press(await findByText('Logg ut'));
await fireEvent.press(await findByText('Logg ut'));
expect(await findByText('Bli medlem')).toBeTruthy();
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
+1 -1
View File
@@ -25,7 +25,7 @@ const Membership = () => {
if (isPending) {
return (
<View style={theme.card}>
<ActivityIndicator color={theme.colors.neufOrange} />
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
</View>
);
}
+25 -18
View File
@@ -1,4 +1,4 @@
import { fireEvent, waitFor } from '@testing-library/react-native';
import { act, fireEvent, waitFor } from '@testing-library/react-native';
import * as Notifications from 'expo-notifications';
import React from 'react';
@@ -47,13 +47,20 @@ beforeEach(() => {
});
it('disables the purchase button without showing a price while membership types load', async () => {
jest.spyOn(api, 'fetchMembershipTypes').mockResolvedValue(testMembershipTypes);
const { getByRole, findByText } = renderProof({ membershipTypes: null });
// Hold the response so the loading state survives the async render
let resolveMembershipTypes!: (types: MembershipTypeOption[]) => void;
jest
.spyOn(api, 'fetchMembershipTypes')
.mockImplementationOnce(() => new Promise((resolve) => (resolveMembershipTypes = resolve)));
const { getByRole, findByText } = await renderProof({ membershipTypes: null });
// The label carries no amount until the query resolves
expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled();
// Once the query resolves the real price takes over
await act(async () => {
resolveMembershipTypes(testMembershipTypes);
});
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
});
@@ -62,24 +69,24 @@ it('shows an error with retry instead of a price when membership types fail to l
.spyOn(api, 'fetchMembershipTypes')
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValue(testMembershipTypes);
const { findByText, getByText, getByRole } = renderProof({ membershipTypes: null });
const { findByText, getByText, getByRole } = await renderProof({ membershipTypes: null });
expect(await findByText('Kunne ikke hente priser')).toBeTruthy();
expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled();
// Retrying hits the recovered API and gets the real price
fireEvent.press(getByText('Prøv igjen'));
await fireEvent.press(getByText('Prøv igjen'));
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled();
});
it('purchases the preselected membership through the stripe payment sheet', async () => {
const requestPaymentSheet = mockPaymentSheet();
const { findByText } = renderProof();
const { findByText } = await renderProof();
// 'Ordinær' (slug: regular) is preselected; the button enables once the
// membership type query has resolved and its price is shown
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
expect(await findByText('Your order is confirmed!')).toBeTruthy();
expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'regular');
@@ -99,10 +106,10 @@ it('purchases the preselected membership through the stripe payment sheet', asyn
it('purchases the selected membership type, not the default', async () => {
const requestPaymentSheet = mockPaymentSheet();
const { findByText } = renderProof();
const { findByText } = await renderProof();
fireEvent.press(await findByText('Student'));
fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
await fireEvent.press(await findByText('Student'));
await fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
expect(await findByText('Your order is confirmed!')).toBeTruthy();
expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'student');
@@ -113,9 +120,9 @@ it('shows the stripe error when the payment sheet is not completed', async () =>
presentPaymentSheet.mockResolvedValueOnce({
error: { code: 'Canceled', message: 'The payment flow has been canceled' },
});
const { findByText, queryByText } = renderProof();
const { findByText, queryByText } = await renderProof();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
expect(await findByText('Error code: Canceled The payment flow has been canceled')).toBeTruthy();
expect(queryByText('Your order is confirmed!')).toBeNull();
@@ -124,9 +131,9 @@ it('shows the stripe error when the payment sheet is not completed', async () =>
it('shows an error and does not present the sheet when the backend rejects the purchase', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(api, 'requestStripePaymentSheet').mockRejectedValueOnce(new Error('Ugyldig medlemskapstype'));
const { findByText, getByRole } = renderProof();
const { findByText, getByRole } = await renderProof();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
expect(await findByText('Oops! Could not initialise payment')).toBeTruthy();
expect(initPaymentSheet).not.toHaveBeenCalled();
@@ -139,9 +146,9 @@ it('shows an error and does not present the sheet when stripe fails to initialis
jest.spyOn(console, 'error').mockImplementation(() => {});
mockPaymentSheet();
initPaymentSheet.mockResolvedValueOnce({ error: { code: 'Failed', message: 'Invalid client secret' } });
const { findByText } = renderProof();
const { findByText } = await renderProof();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
expect(await findByText('Oops! Could not initialise payment')).toBeTruthy();
expect(presentPaymentSheet).not.toHaveBeenCalled();
@@ -149,7 +156,7 @@ it('shows an error and does not present the sheet when stripe fails to initialis
it('refetches user data when the stored membership has expired', async () => {
const fetchUser = jest.fn();
renderProof({ user: expiredMember, fetchUser });
await renderProof({ user: expiredMember, fetchUser });
await waitFor(() => expect(fetchUser).toHaveBeenCalled());
// Notification permission was never granted, so nothing is scheduled
@@ -159,7 +166,7 @@ it('refetches user data when the stored membership has expired', async () => {
it('cancels stale expiry notifications when permission is granted, without scheduling one in the past', async () => {
(Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
const fetchUser = jest.fn();
renderProof({ user: expiredMember, fetchUser });
await renderProof({ user: expiredMember, fetchUser });
// Once the granted permission resolves, the expiry effect runs the cleanup
await waitFor(() => expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled());
+6 -2
View File
@@ -118,7 +118,9 @@ const PurchaseButton = ({
<View style={styles.radioForm}>
<RadioButton data={options} onSelect={onSelect} defaultValue={defaultValue} />
</View>
{paymentLoading && <ActivityIndicator color={theme.colors.neufOrange} />}
{paymentLoading && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
)}
<Button
onPress={onPurchasePress}
fullWidth
@@ -317,7 +319,9 @@ const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofP
onRainEnd={onRainEnd}
/>
<View style={styles.container}>
<ScrollView refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={fetchUser} />}>
<ScrollView
refreshControl={<RefreshControl testID="refresh-control" refreshing={isRefetching} onRefresh={fetchUser} />}
>
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
<MembershipName user={user} />
<Logo user={user} />
+10 -10
View File
@@ -16,8 +16,8 @@ beforeEach(() => {
jest.clearAllMocks();
});
it('renders the delete confirmation for a logged in user', () => {
const { getByText } = renderWithProviders(<UserDelete />, {
it('renders the delete confirmation for a logged in user', async () => {
const { getByText } = await renderWithProviders(<UserDelete />, {
initialToken: 'test-token',
initialUser: testUser,
});
@@ -26,8 +26,8 @@ it('renders the delete confirmation for a logged in user', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
it('renders nothing and navigates back to Membership when not logged in', () => {
const { queryByText } = renderWithProviders(<UserDelete />);
it('renders nothing and navigates back to Membership when not logged in', async () => {
const { queryByText } = await renderWithProviders(<UserDelete />);
expect(queryByText('Slett meg')).toBeNull();
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
@@ -35,13 +35,13 @@ it('renders nothing and navigates back to Membership when not logged in', () =>
it('deletes the account and returns to Membership once the username is typed to confirm', async () => {
const deleteUser = jest.spyOn(api, 'deleteUser').mockResolvedValueOnce(undefined);
const { getByText, getByDisplayValue } = renderWithProviders(<UserDelete />, {
const { getByText, getByDisplayValue } = await renderWithProviders(<UserDelete />, {
initialToken: 'test-token',
initialUser: testUser,
});
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
await fireEvent.changeText(getByDisplayValue(''), 'testbruker');
await fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' }));
expect(deleteUser).toHaveBeenCalledWith('test-token');
@@ -51,13 +51,13 @@ it('shows the server error when deletion fails', async () => {
jest
.spyOn(api, 'deleteUser')
.mockRejectedValueOnce(new ApiError({ detail: 'Kunne ikke slette kontoen akkurat nå' }, 400));
const { getByText, getByDisplayValue, findByText } = renderWithProviders(<UserDelete />, {
const { getByText, getByDisplayValue, findByText } = await renderWithProviders(<UserDelete />, {
initialToken: 'test-token',
initialUser: testUser,
});
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
await fireEvent.changeText(getByDisplayValue(''), 'testbruker');
await fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
expect(await findByText('Kunne ikke slette kontoen akkurat nå')).toBeTruthy();
expect(mockNavigate).not.toHaveBeenCalled();
+3 -1
View File
@@ -56,7 +56,9 @@ const UserDelete = () => {
>
<Text style={!isDisabled ? styles.whiteText : styles.disabledText}>Jeg forstår, slett kontoen min</Text>
</Button>
{isDeletingUser && <ActivityIndicator color={theme.colors.neufOrange} />}
{isDeletingUser && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
)}
</View>
</ScrollView>
);
+25 -48
View File
@@ -1,6 +1,5 @@
import { act, fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
import React from 'react';
import { ActivityIndicator } from 'react-native';
import * as api from '@/api';
import { ApiError } from '@/errors';
@@ -24,37 +23,33 @@ const validFormValues: Record<string, string> = {
Passord: 'hunter2',
};
const fillForm = (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
Object.entries({ ...validFormValues, ...overrides }).forEach(([label, value]) => {
fireEvent.changeText(getByLabelText(label), value);
});
const fillForm = async (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
for (const [label, value] of Object.entries({ ...validFormValues, ...overrides })) {
await fireEvent.changeText(getByLabelText(label), value);
}
};
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders the registration form', () => {
const { getByText } = renderWithProviders(<UserRegister />);
it('renders the registration form', async () => {
const { getByText } = await renderWithProviders(<UserRegister />);
expect(getByText('Fornavn')).toBeTruthy();
expect(getByText('E-post')).toBeTruthy();
expect(getByText('Registrer meg')).toBeTruthy();
});
it('disables the submit button while the form is incomplete', () => {
it('disables the submit button while the form is incomplete', async () => {
const registerUser = jest.spyOn(api, 'registerUser');
const { getByText, getByRole, UNSAFE_queryByType } = renderWithProviders(<UserRegister />);
const { getByText, getByRole, queryByRole } = await renderWithProviders(<UserRegister />);
expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled();
fireEvent.press(getByText('Registrer meg'));
await fireEvent.press(getByText('Registrer meg'));
expect(registerUser).not.toHaveBeenCalled();
expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull();
expect(queryByRole('progressbar')).toBeNull();
});
it('submits the form, shows a spinner while pending and navigates back on success', async () => {
@@ -62,14 +57,14 @@ it('submits the form, shows a spinner while pending and navigates back on succes
const registerUser = jest
.spyOn(api, 'registerUser')
.mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve)));
const { getByText, getByRole, getByLabelText, UNSAFE_getByType } = renderWithProviders(<UserRegister />);
const { getByText, getByRole, getByLabelText } = await renderWithProviders(<UserRegister />);
fillForm(getByLabelText);
await fillForm(getByLabelText);
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
fireEvent.press(getByText('Registrer meg'));
await fireEvent.press(getByText('Registrer meg'));
// the spinner shows while the request is in flight
await waitFor(() => expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy());
await waitFor(() => expect(getByRole('progressbar')).toBeTruthy());
expect(registerUser).toHaveBeenCalledWith(
expect.objectContaining({
first_name: 'Grisen',
@@ -92,22 +87,22 @@ it('shows server field errors on the matching form field', async () => {
jest
.spyOn(api, 'registerUser')
.mockRejectedValueOnce(new ApiError({ email: ['Denne e-postadressen er allerede i bruk'] }, 400));
const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(<UserRegister />);
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
fillForm(getByLabelText);
await fillForm(getByLabelText);
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
fireEvent.press(getByText('Registrer meg'));
await fireEvent.press(getByText('Registrer meg'));
expect(await findByText('Denne e-postadressen er allerede i bruk')).toBeTruthy();
expect(mockGoBack).not.toHaveBeenCalled();
});
it('moves focus down the form when a field is submitted from the keyboard', async () => {
const { getByLabelText } = renderWithProviders(<UserRegister />);
const { getByLabelText } = await renderWithProviders(<UserRegister />);
const labels = ['Fornavn', 'Etternavn', 'E-post', 'Mobilnummer', 'Passord'];
for (const [i, label] of labels.slice(0, -1).entries()) {
fireEvent(getByLabelText(label), 'submitEditing');
await fireEvent(getByLabelText(label), 'submitEditing');
// react-hook-form's setFocus defers the focus call to the next tick
await waitFor(() => expect(focusedFieldLabel()).toBe(labels[i + 1]));
}
@@ -115,41 +110,23 @@ it('moves focus down the form when a field is submitted from the keyboard', asyn
it('shows a generic error when registration fails on the network', async () => {
jest.spyOn(api, 'registerUser').mockRejectedValueOnce(new Error('network down'));
const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(<UserRegister />);
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
fillForm(getByLabelText);
await fillForm(getByLabelText);
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
fireEvent.press(getByText('Registrer meg'));
await fireEvent.press(getByText('Registrer meg'));
expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy();
expect(mockGoBack).not.toHaveBeenCalled();
});
it('warns instead of submitting when the keyboard submits before validation has caught up', async () => {
jest.useFakeTimers();
const registerUser = jest.spyOn(api, 'registerUser');
const { getByLabelText, findByText, queryByText } = renderWithProviders(<UserRegister />);
// Filling in and submitting in the same tick reaches the submit handler while isValid still reflects the empty form
fillForm(getByLabelText);
fireEvent(getByLabelText('Passord'), 'submitEditing');
const message = 'Noen av feltene er ikke fylt ut riktig';
expect(await findByText(message)).toBeTruthy();
expect(registerUser).not.toHaveBeenCalled();
// The toast dismisses itself after five seconds
act(() => jest.advanceTimersByTime(5000));
expect(queryByText(message)).toBeNull();
});
it('rejects an invalid e-mail address without submitting', async () => {
const registerUser = jest.spyOn(api, 'registerUser');
const { getByLabelText, findByText } = renderWithProviders(<UserRegister />);
const { getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
fillForm(getByLabelText, { 'E-post': 'not-an-email' });
await fillForm(getByLabelText, { 'E-post': 'not-an-email' });
// Enter on the last field submits too, bypassing the disabled button
fireEvent(getByLabelText('Passord'), 'submitEditing');
await fireEvent(getByLabelText('Passord'), 'submitEditing');
expect(await findByText('er ikke en gyldig e-post')).toBeTruthy();
expect(registerUser).not.toHaveBeenCalled();
+5 -23
View File
@@ -6,24 +6,13 @@ import Button from '@/components/Button';
import FormErrors from '@/components/FormErrors';
import FormInput from '@/components/FormInput';
import PrivacyButton from '@/components/PrivacyButton';
import Toast from '@/components/Toast';
import theme from '@/theme';
import useUserRegister from './useUserRegister';
const UserRegister = () => {
const {
isRegisteringUser,
onSubmit,
isValid,
isSubmitted,
control,
errors,
registerError,
setFocus,
toast,
setToast,
} = useUserRegister();
const { isRegisteringUser, onSubmit, isValid, isSubmitted, control, errors, registerError, setFocus } =
useUserRegister();
const isDisabled = isRegisteringUser || !isValid;
return (
@@ -78,21 +67,14 @@ const UserRegister = () => {
onSubmitEditing={onSubmit}
/>
{registerError && isSubmitted && <FormErrors errors={registerError} />}
{isRegisteringUser && <ActivityIndicator color={theme.colors.neufOrange} />}
{isRegisteringUser && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
)}
<Button fullWidth disabled={isDisabled} onPress={onSubmit} style={[theme.button, styles.registerButton]}>
<Text style={theme.buttonText}>Registrer meg</Text>
</Button>
</ScrollView>
<PrivacyButton />
<Toast
message={toast?.message}
visible={toast?.visible}
position="bottom"
autoDismiss={5 * 1000}
onDismiss={() => {
setToast(null);
}}
/>
</>
);
};
-15
View File
@@ -1,18 +1,15 @@
import { useNavigation } from '@react-navigation/native';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ApiError } from '@/errors';
import { useRegisterUserMutation } from '@/queries/useRegisterUserMutation';
import type { UserRegisterFormValues } from '@/types/forms';
import { ToastProps } from '@/types/toast';
export type { UserRegisterFormValues };
const useUserRegister = () => {
const navigation = useNavigation();
const { mutate: register, isPending, error } = useRegisterUserMutation();
const [toast, setToast] = useState<ToastProps | null>(null);
const {
control,
@@ -25,16 +22,6 @@ const useUserRegister = () => {
const onSubmit = handleSubmit((values) => {
clearErrors();
if (!isValid) {
setToast({
message: 'Noen av feltene er ikke fylt ut riktig',
visible: true,
preset: 'failure',
});
return;
}
setToast(null);
register(values, {
onSuccess: () => navigation.goBack(),
onError: (registerError) => {
@@ -60,8 +47,6 @@ const useUserRegister = () => {
control,
errors,
setFocus,
toast,
setToast,
};
};
export default useUserRegister;
+2 -2
View File
@@ -48,7 +48,7 @@ interface RenderWithProvidersOptions {
* Renders a screen inside the providers it expects in the real app: a fresh
* query client (no persister), AuthProvider and a NavigationContainer.
*/
export function renderWithProviders(
export async function renderWithProviders(
ui: ReactElement,
{
initialToken = null,
@@ -71,7 +71,7 @@ export function renderWithProviders(
queryClient.setQueryData(queryKeys.membershipTypes, initialMembershipTypes);
}
const result = render(
const result = await render(
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>
<NavigationContainer>{ui}</NavigationContainer>