-
Notifications
You must be signed in to change notification settings - Fork 276
feature: basic example app #1025
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
Changes from 15 commits
2dd6ced
27637ad
eca70de
23729f7
7c736ce
83ab5d1
51635da
8e45434
1a3fcc5
6a9d053
a911b7a
6b57669
71299c6
cd7c5fb
deaae6d
a3317db
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,4 @@ | ||
| { | ||
| "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, | ||
| "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| node_modules/ | ||
| .expo/ | ||
| dist/ | ||
| npm-debug.* | ||
| *.jks | ||
| *.p8 | ||
| *.p12 | ||
| *.key | ||
| *.mobileprovision | ||
| *.orig.* | ||
| web-build/ | ||
|
|
||
| # macOS | ||
| .DS_Store |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| module.exports = { | ||
| arrowParens: 'avoid', | ||
| bracketSameLine: true, | ||
| bracketSpacing: true, | ||
| singleQuote: true, | ||
| trailingComma: 'all', | ||
| printWidth: 100, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import * as React from 'react'; | ||
| import { SafeAreaView } from 'react-native'; | ||
| import { LoginForm } from './components/LoginForm'; | ||
| import { Home } from './components/Home'; | ||
|
|
||
| const App = () => { | ||
| const [user, setUser] = React.useState<string | null>(null); | ||
|
|
||
| return ( | ||
| <SafeAreaView> | ||
| {user == null ? <LoginForm onLoginSuccess={setUser} /> : <Home user={user} />} | ||
| </SafeAreaView> | ||
| ); | ||
| }; | ||
|
|
||
| export default App; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Basic RNTL setup | ||
|
|
||
| This example is shows a basic modern React Native Testing Library setup in a template Expo app. | ||
|
|
||
| The app and related tests written in TypeScript, and it uses recommended practices like: | ||
| - testing large pieces of application instead of small components | ||
| - using `screen`-based queries | ||
| - using recommended query types, e.g. `byText`, `byLabelText`, `byPlaceholderText` over `byTestId` |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,106 @@ | ||||||
| import * as React from 'react'; | ||||||
| import { render, screen, fireEvent } from '@testing-library/react-native'; | ||||||
| import App from '../App'; | ||||||
|
|
||||||
| /** | ||||||
| * A good place to start is having a tests that your component renders correctly. | ||||||
| */ | ||||||
| test('renders correctly', () => { | ||||||
| // Idiom: no need to capture render output, as we will use `screen` for queries. | ||||||
| render(<App />); | ||||||
|
|
||||||
| // Idiom: `getByXxx` is a predicate by itself, but we will use it with `expect().toBeTruthy()` | ||||||
| // to clarify our intent. | ||||||
| expect(screen.getByText('Sign in to Example App')).toBeTruthy(); | ||||||
| }); | ||||||
|
|
||||||
| /** | ||||||
| * Hint: It's best when your tests are similar to what a manual test scenarions would look like, | ||||||
| * i.e. a series of actions taken by the user, followed by a series of assertions verified from | ||||||
| * his point of view. | ||||||
| */ | ||||||
| test('User can sign in successully with correct credentials', async () => { | ||||||
| // Idiom: no need to capture render output, as we will use `screen` for queries. | ||||||
| render(<App />); | ||||||
|
|
||||||
| // Idiom: `getByXxx` is a predicate by itself, but we will use it with `expect().toBeTruthy()` to | ||||||
| // clarify our intent. | ||||||
| // Note: `.toBeTruthy()` is the preferred matcher for checking that elements are present. | ||||||
| expect(screen.getByText('Sign in to Example App')).toBeTruthy(); | ||||||
| expect(screen.getByText('Username')).toBeTruthy(); | ||||||
| expect(screen.getByText('Password')).toBeTruthy(); | ||||||
|
|
||||||
| // Hint: we can use `getByLabelText` to find our text inputs in accessibility-friendly way. | ||||||
| fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); | ||||||
| fireEvent.changeText(screen.getByLabelText('Password'), 'admin1'); | ||||||
|
|
||||||
| // Hint: we can use `getByText` to find our button by its text. | ||||||
| fireEvent.press(screen.getByText('Sign In')); | ||||||
|
|
||||||
| // Idiom: since pressing button triggers async operation we need to use `findBy` query to wait | ||||||
| // for the action to complete. | ||||||
| // Hint: subsequent queries do not need to use `findBy`, because they are used after the async action | ||||||
| // already finished | ||||||
| expect(await screen.findByText('Welcome admin!')).toBeTruthy(); | ||||||
|
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. There's no need to expect everything, right? This reads better as a scenario to me:
Suggested change
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. Yeah thats a good question, I'used that in a similar pattern as we suggest using |
||||||
|
|
||||||
| // Idiom: use `queryByXxx` with `expect().toBeFalsy()` to assess that element is not present. | ||||||
| expect(screen.queryByText('Sign in to Example App')).toBeFalsy(); | ||||||
| expect(screen.queryByText('Username')).toBeFalsy(); | ||||||
| expect(screen.queryByText('Password')).toBeFalsy(); | ||||||
| }); | ||||||
|
|
||||||
| /** | ||||||
| * Another test case based on manual test scenario. | ||||||
| * | ||||||
| * Hint: Try to tests what a user would see and do, instead of assering internal component state | ||||||
| * that is not directly reflected in the UI. | ||||||
| * | ||||||
| * For this reason prefer quries that correspond to things directly observable by the user like: | ||||||
| * `getByText`, `getByLabelText`, `getByPlaceholderText, `getByDisplayValue`, `getByRole`, etc. | ||||||
| * over `getByTestId` which is not directly observable by the user. | ||||||
| * | ||||||
| * Note: that some times you will have to resort to `getByTestId`, but treat it as a last resort. | ||||||
| */ | ||||||
| test('User will see errors for incorrect credentials', async () => { | ||||||
| render(<App />); | ||||||
|
|
||||||
| expect(screen.getByText('Sign in to Example App')).toBeTruthy(); | ||||||
| expect(screen.getByText('Username')).toBeTruthy(); | ||||||
| expect(screen.getByText('Password')).toBeTruthy(); | ||||||
|
|
||||||
| fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); | ||||||
| fireEvent.changeText(screen.getByLabelText('Password'), 'qwerty123'); | ||||||
| fireEvent.press(screen.getByText('Sign In')); | ||||||
|
|
||||||
| // Hint: you can use custom Jest Native matcher to check text content. | ||||||
| expect(await screen.findByLabelText('Error')).toHaveTextContent('Incorrect username or password'); | ||||||
|
|
||||||
| expect(screen.getByText('Sign in to Example App')).toBeTruthy(); | ||||||
| expect(screen.getByText('Username')).toBeTruthy(); | ||||||
| expect(screen.getByText('Password')).toBeTruthy(); | ||||||
| }); | ||||||
|
|
||||||
| /** | ||||||
| * Do not be afraid to write longer test scenarios, with repeating act and assert statements. | ||||||
| */ | ||||||
| test('User can sign in after incorrect attempt', async () => { | ||||||
| render(<App />); | ||||||
|
|
||||||
| expect(screen.getByText('Sign in to Example App')).toBeTruthy(); | ||||||
| expect(screen.getByText('Username')).toBeTruthy(); | ||||||
| expect(screen.getByText('Password')).toBeTruthy(); | ||||||
|
|
||||||
| fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); | ||||||
| fireEvent.changeText(screen.getByLabelText('Password'), 'qwerty123'); | ||||||
| fireEvent.press(screen.getByText('Sign In')); | ||||||
|
|
||||||
| expect(await screen.findByLabelText('Error')).toHaveTextContent('Incorrect username or password'); | ||||||
|
|
||||||
| fireEvent.changeText(screen.getByLabelText('Password'), 'admin1'); | ||||||
| fireEvent.press(screen.getByText('Sign In')); | ||||||
|
|
||||||
| expect(await screen.findByText('Welcome admin!')).toBeTruthy(); | ||||||
| expect(screen.queryByText('Sign in to Example App')).toBeFalsy(); | ||||||
| expect(screen.queryByText('Username')).toBeFalsy(); | ||||||
| expect(screen.queryByText('Password')).toBeFalsy(); | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| { | ||
| "expo": { | ||
| "name": "RNTL Example Basic", | ||
| "slug": "rntl-example-basic", | ||
| "version": "1.0.0", | ||
| "orientation": "portrait", | ||
| "icon": "./assets/icon.png", | ||
| "userInterfaceStyle": "light", | ||
| "splash": { | ||
| "image": "./assets/splash.png", | ||
| "resizeMode": "contain", | ||
| "backgroundColor": "#ffffff" | ||
| }, | ||
| "updates": { | ||
| "fallbackToCacheTimeout": 0 | ||
| }, | ||
| "assetBundlePatterns": ["**/*"], | ||
| "ios": { | ||
| "supportsTablet": true | ||
| }, | ||
| "android": { | ||
| "adaptiveIcon": { | ||
| "foregroundImage": "./assets/adaptive-icon.png", | ||
| "backgroundColor": "#FFFFFF" | ||
| } | ||
| }, | ||
| "web": { | ||
| "favicon": "./assets/favicon.png" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| module.exports = function(api) { | ||
| api.cache(true); | ||
| return { | ||
| presets: ['babel-preset-expo'], | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import * as React from 'react'; | ||
| import { StyleSheet, View, Text } from 'react-native'; | ||
|
|
||
| type Props = { | ||
| user: string; | ||
| }; | ||
|
|
||
| export function Home({ user }: Props) { | ||
| return ( | ||
| <View style={styles.container}> | ||
| <Text style={styles.title}>Welcome {user}!</Text> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| container: { | ||
| padding: 20, | ||
| }, | ||
| title: { | ||
| alignSelf: 'center', | ||
| fontSize: 24, | ||
| marginTop: 8, | ||
| marginBottom: 40, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import * as React from 'react'; | ||
| import { | ||
| StyleSheet, | ||
| View, | ||
| Text, | ||
| TextInput, | ||
| TouchableOpacity, | ||
| ActivityIndicator, | ||
| } from 'react-native'; | ||
|
|
||
| type Props = { | ||
| onLoginSuccess: (user: string) => void; | ||
| }; | ||
|
|
||
| export function LoginForm({ onLoginSuccess }: Props) { | ||
| const [username, setUsername] = React.useState(''); | ||
| const [password, setPassword] = React.useState(''); | ||
| const [error, setError] = React.useState<string | undefined>(); | ||
| const [isLoading, setIsLoading] = React.useState(false); | ||
|
|
||
| const handleSignIn = async () => { | ||
| setIsLoading(true); | ||
|
|
||
| const user = await authUser(username, password); | ||
| setIsLoading(false); | ||
|
|
||
| if (user) { | ||
| setError(undefined); | ||
| onLoginSuccess(user); | ||
| } else { | ||
| setError('Incorrect username or password'); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <View style={styles.container}> | ||
| <Text style={styles.title}>Sign in to Example App</Text> | ||
|
|
||
| <Text style={styles.textLabel}>Username</Text> | ||
| <TextInput | ||
| value={username} | ||
| onChangeText={setUsername} | ||
| accessibilityLabel="Username" | ||
| autoCapitalize="none" | ||
| style={styles.textInput} | ||
| /> | ||
|
|
||
| <Text style={styles.textLabel}>Password</Text> | ||
| <TextInput | ||
| value={password} | ||
| onChangeText={setPassword} | ||
| accessibilityLabel="Password" | ||
| secureTextEntry={true} | ||
| style={styles.textInput} | ||
| /> | ||
|
|
||
| {error && ( | ||
| <Text accessibilityLabel="Error" style={styles.validator}> | ||
| {error} | ||
| </Text> | ||
| )} | ||
|
|
||
| <TouchableOpacity onPress={handleSignIn} style={styles.button}> | ||
| {isLoading ? ( | ||
| <ActivityIndicator color="white" /> | ||
| ) : ( | ||
| <Text style={styles.buttonText}>Sign In</Text> | ||
| )} | ||
| </TouchableOpacity> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Fake authentication function according to our abilities. | ||
| * @param username The username to authenticate. | ||
| * @param password The password to authenticate. | ||
| * @returns username if the username and password are correct, null otherwise. | ||
| */ | ||
| async function authUser(username: string, password: string): Promise<string | null> { | ||
| return new Promise(resolve => | ||
| setTimeout(() => { | ||
| const hasValidCredentials = username === 'admin' && password === 'admin1'; | ||
| resolve(hasValidCredentials ? username : null); | ||
| }, 250), | ||
| ); | ||
| } | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| container: { | ||
| padding: 20, | ||
| }, | ||
| title: { | ||
| alignSelf: 'center', | ||
| fontSize: 24, | ||
| marginTop: 8, | ||
| marginBottom: 40, | ||
| }, | ||
| textLabel: { | ||
| fontSize: 16, | ||
| color: '#444', | ||
| }, | ||
| textInput: { | ||
| fontSize: 20, | ||
| padding: 8, | ||
| marginVertical: 8, | ||
| borderColor: 'black', | ||
| borderWidth: 1, | ||
| }, | ||
| button: { | ||
| backgroundColor: '#3256a8', | ||
| padding: 16, | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| marginTop: 20, | ||
| minHeight: 56, | ||
| }, | ||
| buttonText: { | ||
| fontSize: 20, | ||
| fontWeight: '600', | ||
| color: 'white', | ||
| }, | ||
| validator: { | ||
| color: 'red', | ||
| fontSize: 18, | ||
| marginTop: 8, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /* eslint-disable no-undef, import/no-extraneous-dependencies */ | ||
|
|
||
| // Import Jest Native matchers | ||
| import '@testing-library/jest-native/extend-expect'; | ||
|
|
||
| // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing | ||
| jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| module.exports = { | ||
| preset: '@testing-library/react-native', | ||
| moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], | ||
| setupFilesAfterEnv: ['./jest-setup.js'], | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.