Testowanie aplikacji React Native – JEST, testy jednostkowe oraz snapshotowe

W ostatnim projekcie, w którym brałem udział CTO wymagał od programistów jak największego pokrycia kodu testami. W zasadzie sugerował, żeby dążyć do 100% test coverage.

W związku z tym przez ostatnie kilka miesięcy musiałem nauczyć się, w jaki sposób testować funkcje, komponenty oraz ekrany aplikacji w React Native. Ogólnie testowania aplikacji mobilnych.

W tym artykule postaram się przekazać część tego, czego się nauczyłem:

Po co pisać testy?

testowanie asynchronicznego kodu
Warto rozpisać podstawowe testy snapshotowe komponentów i ekranów, oraz unit testy używanych funkcji - żeby oszczędzić sobie czasu na manualnym sprawdzaniu działania aplikacji.

Programista bez buga w kodzie, jest jak żołnierz bez karabinu.

Błędy się nam zdarzały, zdarzają i będą zdarzać – są nieuniknione. To, czego można jednak uniknąć, to dostarczania aplikacji z błędami do rąk klienta.

Możemy oczywiście polegać na testowaniu manualnym – ale tester manualny też jest człowiekiem, nie wyłapie wszystkich nieścisłości w aplikacji.

Po co pisać testy?
Dobrze napisane testy dadzą nam pewność, że nasz kod pozbawiony jest błędów, co więcej zapewnią nas, że funkcjonalności, które pisaliśmy X miesięcy temu wciąż działają po wprowadzeniu zmian w innych częściach kodu, refactorze, czy przejściu na inną wersję którejś z używanych bibliotek.

Testy mogą służyć również za swoistą “dokumentację” projektu.

Często łatwiej jest zrozumieć, co robi dana funkcja czy komponent patrząc na kod jego testu, niż na sam kod funkcji.

Na tym swoją filozofię opierał wspominany przeze mnie wcześniej CTO, który uważał, że dodanie testów umożliwia nietechnicznemu Product Ownerowi lepsze zrozumienie tego, co robią wprowadzone zmiany, a 100% test coverage zapewni go, że wprowadzone zmiany faktycznie działają.

Niektórzy podają jako argument, że pisanie testów automatycznych oszczędza czas na manualnym QA.

Z tym nie do końca się zgodzę.

Test danej funkcjonalności faktycznie wystarczy napisać raz, natomiast manualnie wypadałoby testować ją za każdym razem gdy wprowadzamy jakieś zmiany w kodzie, jednak w pewnych przypadkach pisanie testu bywa dość czasochłonne.

Podsumowując

Czy warto pisać testy?
Jak najbardziej, jeszcze jak!

Czy przy mniejszych projektach warto dążyć do 100% test coverage?
Raczej nie.

Nie ma sensu na siłę starać się przetestować każdego zakamarka kodu – szczególnie jeśli napisanie testu miałoby zająć więcej niż napisanie samej funkcjonalności.

Czym są snapshoty?

Czym są snapshoty?
Testy snapshotowe polegają na automatycznym utworzeniu pliku reprezentującego wygląd danego komponentu/ekranu aplikacji, a następnie porównaniu go z już istniejącym (o ile istnieje).

Po stworzeniu nowego komponentu generujemy dla niego snapshot. Jako że wcześniej  nie istniał inny snapshot danego komponentu, nie następuje porównanie snapshotów, a wygenerowany snapshot zostaje zapisany.

Następnie za każdym razem gdy testujemy aplikację generowany jest nowy snapshot danego komponentu i porównywany z zapisanym.

Jeśli snapshoty różnią się, test kończy się niepowodzeniem. Możemy wtedy sami stwierdzić czy zmiana była intencjonalna.

  • Jeśli była - uaktualniamy snapshot, żeby nowa wersja komponentu była używana do porównania.

  • Jeśli nie - poczynimy odpowiednie zmiany w kodzie.

Przykładowy schemat testu snapshotowego
Przykładowy schemat działania testu snapshotowego.

Warto zobrazować to na konkretnym przykładzie.

Do stylowania komponentów oraz implementacji theme aplikacji wykorzystamy styled-components.

Tworzenie testów snapshotowych umożliwia framework JEST, który dołączany jest domyślnie do projektów React Native.

Weźmy na przykład prosty komponent guzika napisany z użyciem styled components, którego kolor tekstu oraz kolor tła zdefiniowane są w Theme aplikacji.

import React from 'react';
import styled from 'styled-components/native';
 
type ButtonProps = {
    label: string;
    onPress: () => void;
};
 
const ButtonContainer = styled.TouchableOpacity`
    height: 56px;
    width: 100%;
    margin-top: 17px;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    background-color: ${props => props.theme.colors.background.dark};
`;
 
const ButtonText = styled.Text`
    font-size:16px
    color: ${props => props.theme.colors.text.lightGrey}
    line-height:21px
`;
 
const SimpleButton: React.FC<ButtonProps> = ({label, onPress}) => {
    return (
        <ButtonContainer onPress={onPress}>
            <ButtonText>{label}</ButtonText>
        </ButtonContainer>
    );
};
 
export default SimpleButton;
export const theme: styledComponents.DefaultTheme = {
 colors: {
   system: {
     tokenOnWhite: '#14CA85',
     tokenGreen: '#1CFFB5',
     tokenGreen60: '#6FFFE3',
     tokenGreen30: '#B9FFE8',
     tokenGreen10: '#E7FFF7',
     tabBarBorder: '#E2E3E4',
   },
   text: {
     black: '#000000',
     darkGrey: '#36354A',
     grey: '#686777',
     lightGrey: '#B3B5BD',
     highlightGrey: '#D2D2D7',
   },
   status: {
     dangerous: '#FF0000',
     bad: '#FF6B00',
     good: '#15AA2C',
   },
   background: {
     light: '#FFFFFF',
     dark: '#F8F8FA',
   },
 },
Scieżka zapisu testów snapshotowych
Testy zapisujemy w podfolderze głównego folderu aplikacji o nazwie __tests__.

Testy powinny mieć następującą nazwę:
’${NazwaTestowanegoKomponentu/Funckji/Screena}-test.tsx`
(z rozszerzenim .ts jeśli testujemy kod niezawierający tagów tsx).

W celu utworzenia pojedynczego testu wykorzystajmy funkcję test, która jako swoje argumenty przyjmuje nazwę testu oraz funkcję, za pomocą której będziemy testować komponent.

Nazwa testu powinna jednocześnie wskazywać na to co dany test sprawdza. Wiele podobnych testów możemy łączyć w bloki za pomocą funkcji describe. Wiele testów możemy łączyć w blok describe.

describe('Simple Button', () => {
    test('renders correctly', () => {
        //tu wprowadzimy treść testu 1
    });
    test('does some other awesome thing!', () => {
        //tu wprowadzimy treść testu 2
    });
});

Pierwszy test snapshot

Stwórzmy pierwszy test snapshot w celu sprawdzenia, czy button renderuje się poprawnie. 

Opis funkcji i komend

  • Komenda renderer.create() renderuje komponent, który następnie konwertowany jest do snapshota metodą to JSON.

  • Funkcja expect().toMatchSnapshot() sprawdza, czy wygenerowany wcześniej snapshot odpowiada zapisanemu, o ile istnieje zapisany snapshot.

  • .toMatchSnapshot() to tak zwany matcher - funkcja JEST wykorzystywana do porównywania/ testowania wartości na różny sposób.

Więcej na temat matcherów dowiesz się w dalszej części artykułu.

import React from 'react';
import SimpleButton from 'components/SimpleButton';
import 'react-native';
import renderer from 'react-test-renderer';
import {ThemeProvider} from 'styled-components/native';
import {theme} from 'styles/theme';
 
describe('SimpleButton', () => {
    test('renders correctly', () => {
        const simpleButtonRender = renderer
            .create(
            <SimpleButton onPress={jest.fn()} label={'Test'} />
            )
            .toJSON();
            expect(simpleButtonRender).toMatchSnapshot();
    });
});

Testy uruchamia się za pomocą komendy yarn jest.

Taki test zakończy się niepowodzeniem, Theme aplikacji, od którego zależy wygląd komponentu, nie będzie w nim widoczny.

Żeby rozwiązać ten problem, należy owinąć komponent w provider motywu.

import React from 'react';
import SimpleButton from 'components/SimpleButton';
import 'react-native';
import renderer from 'react-test-renderer';
import {ThemeProvider} from 'styled-components/native';
import {theme} from 'styles/theme';
 
describe('SimpleButton', () => {
    test('renders correctly', () => {
        const simpleButtonRender = renderer
            .create(
                <ThemeProvider theme={theme}>
                    <SimpleButton onPress={jest.fn()} label={'Test'} />
                </ThemeProvider>,
            )
            .toJSON();
        expect(simpleButtonRender).toMatchSnapshot();
    });
});

Rezultatem jest utworzenie pliku SimpleButton-test.snap (ponieważ wcześniej nie istniał żaden snapshot).

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`SimpleButton renders correctly 1`] = `
    <View
        accessible={true}
        collapsable={false}
        focusable={true}
        onClick={[Function]}
        onResponderGrant={[Function]}
        onResponderMove={[Function]}
        onResponderRelease={[Function]}
        onResponderTerminate={[Function]}
        onResponderTerminationRequest={[Function]}
        onStartShouldSetResponder={[Function]}
        style={
            Object {
                "alignItems": "center",
                "backgroundColor": "#F8F8FA",
                "flexDirection": "row",
                "height": 92,
                "justifyContent": "center",
                "marginTop": 17,
                "opacity": 1,
                "width": "100%",
            }
        }
    >
        <Text
            style={
                Array [
                    Object {
                        "color": "#B3B5BD",
                        "fontSize": 16,
                        "lineHeight": 21,
                    },
                ]
            }
        >
            Test
        </Text>
    </View>
`;

Spróbujmy teraz zmienić jeden z kolorów w theme aplikacji, a następnie uruchomić napisany wcześniej test.

background: {
    light: '#FFFFFF',
    dark: '#013220',
},

Test zakończy się niepowodzeniem.

Informacje o zmianach jakie zaszły w snapshocie

Jak widać na powyższym zrzucie ekranu, JEST informuje nas dokładnie jakie zmiany zaszły w snapshocie, dzięki temu możemy sprawdzić, czy były one intencjonalne.

W tym przypadku jeśli nie chcielibyśmy, aby zmiana koloru w theme wpłynęła na komponent, powinniśmy dodać nowy kolor i przypisać go jako kolor tła guzika.

Jeśli jednak zmiana była intencjonalna, możemy użyć flagi -u, aby zaktualizować kolor w snapshotach.

Zmiany jakie zaszły w UI
Raport zmian, które zaszły w UI.

Testy snapshotowe umożliwiają programiście zdeterminowanie jak zmiany w kodzie oddziałują na zmiany w UI oraz zapobiegają niespodziewanym zmianom w UI. 

Zaletą testów snapshotowych jest ich prostota oraz szybkość pisania.

Główną wadą jest to, że snapshoty są testami porównawczymi.

Co za tym idzie przed stworzeniem pierwotnego snapshota danego komponentu, z którym porównywane są wszystkie kolejne snapshoty,  trzeba upewnić się, że dany komponent wygląda i działa prawidłowo (najczęściej poprzez manualne testowanie).

JEST matchers i testowanie funkcji

JEST matchers i testowanie funkcji

W poprzednim rozdziale poznaliśmy matcher toMatchSnapshot pozwalający na sprawdzenie, czy generowany snapshot jest zgodny z zapisanym. 

JEST oferuje nam znacznie więcej różnorodnych matcherów. Rzecz jasna matcher wybieramy w zależności od naszego przypadku testowego.

Napiszmy kolejny test dla prostej funkcji

export const exampleFunction = (a: number, b: number) => {
    if (b === 0) {
        throw new Error("Can't divide by 0");
    } 
    else {
        return a / b;
    }
};

Banalna funkcja, która zwraca nam wartość dzielenia dwóch argumentów, a i b, chyba że b jest równe 0 (wtedy rzuca błąd).

Przetestujmy najpierw czy funkcja faktycznie rzuci błąd w przypadku gdy b===0.
Swtórzmy plik exampleFunction-test.tsx w folderze __tests__  i napiszmy prosty test z użyciem matchera toThrow.

import 'react-native';
import {exampleFunction} from 'utils/exampleFunction';
 
describe('example function', () => {
    test('throws an error when second argument is 0', () => {
        expect(() => exampleFunction(1, 0)).toThrow();
    });
});

W teście sprawdzamy, czy exampleFunction z argumentami 1 i 0 faktycznie rzuci błąd.

exampleFunction owinięte jest w funkcję strzałkową, ponieważ w innym przypadku JEST nie mógłby wyłapać rzucanego błędu. Więcej informacja o tym znajdziecie w dokumentacji JEST.

Informacja o zaliczonym teście
Jak widać, taki test kończy się sukcesem.

Możemy również przetestować, czy wiadomość błędu jest prawidłowa. W tym celu możemy dodać string lub wyrażenie regularne jako argument matchera toThrow().

  • Jeśli wpiszemy string - JEST sprawdzi, czy wiadomość błędu zawiera wpisany string.

  • Jeśli wpiszemy wyrażenie regularne - sprawdzi, czy pasuje do wyrażenia.

import 'react-native';
import {exampleFunction} from 'utils/exampleFunction';
 
describe('example function', () => {
    test('throws an error when second argument is 0', () => {
        expect(() => exampleFunction(1, 0)).toThrow();
    });
    test('throws error with error message that includes divide', () => {
        expect(() => exampleFunction(1, 0)).toThrow('divide');
    });
    test('throws error with correct error message', () => {
        expect(() => exampleFunction(1, 0)).toThrow(/^Can't divide by 0$/);
    });
});
Efekt uruchomionego testu
Efekt uruchomionego testu.

Efekty uruchomionej komendy 
Efektem uruchomienia komendy jarn jest, jest uruchomienie wszystkich testów.
Jeśli chcemy uruchomić jedynie jeden z testów, wystarczy wpisać jego nazwę np. yarn jest exampleFunction-test.tsx

Możemy również sprawdzić, czy wartość zwracana przez funkcję jest zgodna z oczekiwaną. W tym celu można użyć matchera toBe lub toEqual.

test('returns correct value', () => {
    expect(exampleFunction(6, 2)).toEqual(3);
});

Możemy również sprawdzić, czy wynik funkcji nie jest równy innej wartości, do tego może posłużyć matcher not.

test('returns correct value', () => {
    expect(exampleFunction(6, 2)).toEqual(3);
    expect(exampleFunction(6, 2)).not.toEqual(6);
});

W naszym przypadku to czy użyjemy toBe, czy toEqual nie ma znaczenia, różnica pojawiłaby się gdybyśmy porównywali obiekty.

  • toBe sprawdzi identyczność referencji instancji obiektów.

  • toEqual porówna wszystkie wartości pól obiektu.

Pokażę to na przykładzie
Zmodyfikujmy naszą przykładową funkcję tak, aby zwracała obiekt zawierający sumę oraz iloraz argumentów i zapiszmy ją jako nową funckję exampleFunction2.

export const exampleFunction2 = (a: number, b: number) => {
    if (b === 0) {
    throw new Error("Can't divide by 0");
    } 
    else {
    return {sum: a + b, quotient: a / b};
    }
};

Napiszmy następujący test, żeby zobrazować różnicę pomiędzy toEqual, a toBe.

import 'react-native';
import {exampleFunction2} from 'utils/exampleFunction2';
 
describe('example function', () => {
    test('returns correct value', () => {
        const expectedResult = {sum: 8, quotient: 3};
        expect(exampleFunction2(6, 2)).toEqual(expectedResult);
        expect(exampleFunction2(6, 2)).toBe(expectedResult);
    });
});
Informacje o zmianach jakie zaszły w snapshocie_2
Jak widać użycie matchera toBe kończy się niepowodzeniem testu, ponieważ w tym przypadku nie porównane są referencje, a nie poszczególne wartości pól obiektu.

Testowanie asynchronicznego kodu

Testowanie asynchronicznego kodu najczęściej wiąże się z komunikacją z backendem.

Do tego przykładu użyjmy klienta axios oraz otwartego API – Agify.

Zainstalujmy axios komendą yarn add axios, a następnie stwórzmy przykładowe zapytanie do API.

import axios from 'axios';
 
export const axiosCall = async () => {
    const response = axios.get<{age: number; count: number; name: string}>
    ('https://api.agify.io/', {
        params: {name: 'michael'},
    });
    return response.data;
};

Efektem wywołania takiego zapytania jest zwrócenie obiektu:

{“age”: 70, “count”:233482, “name”: “michael”}

Sposoby testowania asynchronicznego kodu

Omówimy teraz trzy sposoby testowania asynchronicznego kodu.

#1

Owinięcie testu w async/await

Możemy poczekać na dane z API, a następnie używając odpowiedniego matchera porównać je do przewidywanej wartości.

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
 
describe('axios call', () => {
    test('returns expected value - async await test', async () => {
        const data = await axiosCall();
        expect(data).toEqual({age: 70, count: 233482, name: 'michael'});
    });
});
#2

Zwrócenie Promise i wywołanie funkcji expect w bloku then()

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
 
describe('axios call', () => {
    test('returns expected value - promise', () => {
        return axiosCall().then(data =>
            expect(data).toEqual({age: 70, count: 233482, name: 'michael'}),
        );
    });
});
#3

Użycie matcherów .rejects/ .resolves

Powodują one, że JEST czeka odpowiednio na odrzucenie lub rozstrzygnięcie promise.

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
 
describe('axios call', () => {
    test('returns expected value - resolves matcher', () => {
        return expect(axiosCall()).resolves.toEqual({
            age: 70,
            count: 233482,
            name: 'michael',
        });
    });
});
JEST fake timers jest zestawem funkcji służących do testowania funkcji związanego z upływem czasu.

Z mojego doświadczenia wynika, że testy asynchroniczne psują się przy używaniu JEST fake timers.

Z tego względu warto przed takimi testami wywołać jest.useRealTimers() lub jest.useFakeTimers(“legacy”).

W tym celu można sięgnąć po funkcje służące do setupu testów. JEST dostarcza nam 4 funkcje, które (jak same nazwy wskazują) umożliwiają wykonanie wybranego kodu przed lub po wszystkich kodach oraz przed i po wszystkich testach.

  • beforeAll,
  • afterAll,
  • beforeEach,
  • afterEach.

W tym przypadku chcemy ustawić realTimers przed wszystkimi testami.

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
beforeAll(() => {
    jest.useRealTimers();
});
describe('axios call', () => {
    test('returns expected value - async await test', async () => {
        const data = await axiosCall();
        expect(data).toEqual({age: 70, count: 233482, name: 'michael'});
    });
    test('returns expected value - promise', () => {
        return axiosCall().then(data =>
            expect(data).toEqual({age: 70, count: 233482, name: 'michael'}),
        );
    });
    test('returns expected value - resolves matcher', () => {
        return expect(axiosCall()).resolves.toEqual({
            age: 70,
            count: 233482,
            name: 'michael',
        });
    });
});

Mocks

Mocks
Powodzenie testów prowadzonych w sposób opisany powyżej zależy między innymi od tego czy backend jest dostępny w danym momencie.

Możesz się poczuć teraz lekko oszukany, ale…
testowanie “strzałów” do API w sposób przedstawiony powyżej nie jest najlepszym pomysłem.

Głównymi wadami takiego podejścia jest to, że testy są wolniejsze, niestabilne, oraz powodują niepotrzebne obciążenie backendu. W rozwiązaniu tych problemów pomogą nam mocks.

Funkcje mock to nic innego, jak funkcje, które w pewien kontrolowany sposób udają zachowanie innych funkcji na potrzeby środowiska testowego. Mockować można również całe moduły.

W naszym przypadku możemy zmockować moduł axios wywołując jest.mock(“axios”),
a następnie za pomocą mockResolvedValue ustawić przewidywaną wartość odpowiedzi.

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
import axios from 'axios';

beforeAll(() => {
    jest.useRealTimers();
});
jest.mock('axios');
describe('axios call', () => {
    test('should fetch users', () => {
        const axiosCallResponse = {age: 70, count: 233482, name: 'michael'};
        const mockResponse = {data: axiosCallResponse};
        (axios.get as jest.Mock).mockResolvedValue(mockResponse);
        return axiosCall().then(data => expect(data).toEqual(axiosCallResponse));
    });
});

W tym przypadku imitujemy samą zwracaną wartość funkcji axios.get.

Możemy również imitować całą implementację tej funkcji. Z pomocą przyjdzie nam mockImplementation().

import 'react-native';
import {axiosCall} from 'utils/axiosCall';
import axios from 'axios';

beforeAll(() => {
    jest.useRealTimers();
});
jest.mock('axios');
describe('axios call', () => {
    test('should fetch users', () => {
        const axiosCallResponse = {age: 70, count: 233482, name: 'michael'};
        const mockResponse = {data: axiosCallResponse};
        (axios.get as jest.Mock).mockImplementation(() =>
            Promise.resolve(mockResponse),
        );
        return axiosCall().then(data => expect(data).toEqual(axiosCallResponse));
    });
});

Innym sposobem na utworzenie mock funkcji jest użycie jest.spyOn. W ten sposób możemy zmockować np. funkcję zwracającą obecną datę. 

Przydaje się to np. gdy tworzymy snapshoty komponentu, który wyświetla ciąg znaków zależnych od obecnej daty.

Snapshoty takiego komponentu różniłyby się od siebie w zależności od tego, w jaki dzień zostaną zrobione, co nie jest pożądanym zachowaniem.

jest
    .spyOn(global.Date, 'now')
    .mockImplementation(() =>
        new Date('2022-05-14Z').valueOf()
    );

Ten sam efekt można osiągnąć stosując jest.fn().

jest
    .fn(Date.now)
    .mockImplementation(() => new Date('2022-05-14').valueOf());

W tym przypadku jedyną różnicą pomiędzy jest.fn, a jest.spyOn jest to, że stosując jest.spyOn możemy przywrócić pierwotną implementację mockowanej funkcji za pomocą metody mockRestore().

Podsumowując

Mockowanie to technika umożliwiająca testowanie części kodu, które korzystają z różnych zależności np. modułów, dzięki zastępowaniu ich obiektami/funkcjami, które możemy kontrolować i badać.

  • jest.mock - stosujemy do mockowanie modułów.

  • jest.fn - stosujemy do mockowania funkcji.

  • jest.spyOn - stosujemy do mockowania funkcji, dodatkowo umożliwia nam przywrócenie pierwotnej implementacji.

React-native-testing-library

React-native-testing-library
Główną funkcją tego narzędzia jest tworzenie testów skupionych na użytkowniku i na tym w jaki sposób wchodzi on w interakcję z aplikacją. Narzędzie to więc świetnie nadaje się do testowania akcji użytkownika i reakcji aplikacji na te akcje.

Kolejnym narzędziem, które można używać do testowania JEST react-native-testing-library.

Nie jest ono domyślnie dołączone do react-native, więc wymaga osobnej instalacji.

yarn add --dev @testing-library/react-native
yarn add --dev @testing-library/jest-native

Główną funkcją tego narzędzia jest tworzenie testów skupionych na użytkowniku i na tym, w jaki sposób wchodzi on w interakcję z aplikacją. Narzędzie to więc świetnie nadaje się do testowania akcji użytkownika i reakcji aplikacji na te akcje.

Działanie react-native-testing library
Dostarcza metodę fireEvent(), która przyjmuje jako argument wyrenderowany komponent oraz akcję, która ma na nim zostać wykonana.
To właśnie dzięki niej możemy sprawdzać, czy nasza aplikacja prawidłowo reaguje na interakcje ze strony użytkownika.

Napiszmy teraz przykładowy ekran zawierający, napisany wcześniej przycisk SimpleButton, którego naciśnięcie spowoduje wywołanie funkcji axiosCall, a dane otrzymane z API zostaną wyświetlone w komponentcie <Text> powyżej guzika.

import React, {useState} from 'react';
import SimpleButton from 'components/SimpleButton';
import {styled} from 'styles/theme';
import {axiosCall} from 'utils/axiosCall';
 
const StyledSafeAreaView = styled.SafeAreaView`
    align-items: center;
    margin-horizontal: 20px;
`;
const StyledText = styled.Text`
    margin-vertical: 20px;
`;
const TestScreen: React.FC = () => {
    const [data, setData] = useState({age: 0, count: 0, name: 'noname'});
    const callAPI = () => {
        axiosCall()
            .then(response => setData(response))
            .catch(err => console.warn(err));
    };
    return (
        <StyledSafeAreaView>
            <StyledText>{`age: ${data.age} name: ${data.nam </StyledText>
            <SimpleButton label={'Call API'} onPress={callAPI} />
        </StyledSafeAreaView>
    );
};
 
export default TestScreen;
Przycisk SimpleButton, jego naciśnięcie powoduje wywołanie funkcji axiosCall.
Przycisk SimpleButton - jego naciśnięcie powoduje wywołanie funkcji axiosCall.

A teraz napiszmy test tego ekranu (analogicznie do tego, jak pisaliśmy poprzednie testy). Jedyną różnicą będzie użycie renderera z  testing-library-react-native, a nie JEST.

import React from 'react';
import {render} from '@testing-library/react-native';
import TestScreen from 'screens/TestScreen';
import {withTheme} from 'styles/theme';
 
describe('TestScreen', () => {
    test('renders correctly', () => {
        const testScreenRender = render(withTheme(<TestScreen />));
        expect(testScreenRender.toJSON()).toMatchSnapshot();
    });
});

Efektem tego testu jest zapisanie następującego snapshota.
Zwróć uwagę, że ekran wyświetla teraz domyślne wartości age: 0 i name: noname.

exports[`TestScreen renders correctly 1`] = `
    <RCTSafeAreaView
        emulateUnlessSupported={true}
        style={
            Array [
                Object {
                    "alignItems": "center",
                    "marginHorizontal": 20,
                },
            ]
        }
    >
        <Text
            style={
                Array [
                    Object {
                        "marginVertical": 20,
                    },
                ]
            }
        >
            age: 0 name: noname
        </Text>
        <View
            accessible={true}
            collapsable={false}
            focusable={true}
            onClick={[Function]}
            onResponderGrant={[Function]}
            onResponderMove={[Function]}
            onResponderRelease={[Function]}
            onResponderTerminate={[Function]}
            onResponderTerminationRequest={[Function]}
            onStartShouldSetResponder={[Function]}
            style={
                Object {
                    "alignItems": "center",
                    "backgroundColor": "#013220",
                    "flexDirection": "row",
                    "height": 92,
                    "justifyContent": "center",
                    "marginTop": 17,
                    "opacity": 1,
                    "width": "100%",
                }
            }
            testID="simpleButtonTestID"
        >
            <Text
                style={
                    Array [
                        Object {
                            "color": "#B3B5BD",
                            "fontFamily": "RobotoMono-Medium",
                            "fontSize": 16,
                            "lineHeight": 21,
                        },
                     ]
                }
            >
                Call API
            </Text>
        </View>
    </RCTSafeAreaView>
`;

Spróbujmy teraz sprawdzić, czy UI reaguje poprawnie na naciśnięcie przycisku. Zacznijmy od zmockowania funkcji axiosCall tak jak robiliśmy to wcześniej.

const axiosCall = require('utils/axiosCall');
    const mockAxiosCall = jest
        .spyOn(axiosCall, 'axiosCall')
        .mockResolvedValue({age: 70, count: 233482, name: 'michael'});

Następnie zasymulujmy naciśnięcie przycisku przez użytkownika.

fireEvent(testScreenRender.getByTestId('simpleButtonTestID'), 'press');

Teraz wykonajmy snapshot, który zobrazuje wygląd ekranu po naciśnięciu przycisku. Musimy w tym celu poczekać na to, aż funkcja mockAxiosCall zostanie wywołana.

W tym celu możemy użyć funkcji waitFor, która jak sama nazwa wskazuje –  powoduje, że test czeka na jakieś wydarzenie.

Aby sprawdzić czy funkcja mockAxiosCall została wywołana, możemy użyć matchera toHaveBeenCalledTimes(1).

await waitFor(() => expect(mockAxiosCall).toHaveBeenCalledTimes(1))

Aby test działał poprawnie musimy cały test owinąć w async.

Na koniec, gdy funkcja waitFor zaczeka na wywołanie mockAxiosCall musimy utworzyć kolejny snapshot

await waitFor(() => expect(mockAxiosCall).toHaveBeenCalledTimes(1)).then(
    () => expect(testScreenRender.toJSON()).toMatchSnapshot(),
);

Cały test powinien wyglądać następująco:

import React from 'react';
import {fireEvent, render, waitFor} from '@testing-library/react-native';
import TestScreen from 'screens/TestScreen';
import {withTheme} from 'styles/theme';
 
describe('TestScreen', () => {
    test('renders correctly', () => {
        const testScreenRender = render(withTheme(<TestScreen />));
        expect(testScreenRender.toJSON()).toMatchSnapshot();
    });
    test('renders correctly after user button press', async () => {
        const testScreenRender = render(withTheme(<TestScreen />));
        const axiosCall = require('utils/axiosCall');
        const mockAxiosCall = jest
            .spyOn(axiosCall, 'axiosCall')
            .mockResolvedValue({age: 70, count: 233482, name: 'michael'});
        fireEvent(testScreenRender.getByTestId('simpleButtonTestID'), 'press');
        await waitFor(() => expect(mockAxiosCall).toHaveBeenCalledTimes(1)).then(
            () => expect(testScreenRender.toJSON()).toMatchSnapshot(),
        );
    });
});

Efektem wywołania testu jest powstanie następującego snapshota.

Uwaga
W tym snapshocie wartości age i name pochodzą już z funkcji mockAxiosCall, co potwierdza, że UI reaguje poprawnie na akcje użytkownika.

exports[`TestScreen renders correctly after user button press 1`] = `
<RCTSafeAreaView
  emulateUnlessSupported={true}
  style={
    Array [
      Object {
        "alignItems": "center",
        "marginHorizontal": 20,
      },
    ]
  }
>
  <Text
    style={
      Array [
        Object {
          "marginVertical": 20,
        },
      ]
    }
  >
    age: 70 name: michael
  </Text>
  <View
    accessible={true}
    collapsable={false}
    focusable={true}
    onClick={[Function]}
    onResponderGrant={[Function]}
    onResponderMove={[Function]}
    onResponderRelease={[Function]}
    onResponderTerminate={[Function]}
    onResponderTerminationRequest={[Function]}
    onStartShouldSetResponder={[Function]}
    style={
      Object {
        "alignItems": "center",
        "backgroundColor": "#013220",
        "flexDirection": "row",
        "height": 92,
        "justifyContent": "center",
        "marginTop": 17,
        "opacity": 1,
        "width": "100%",
      }
    }
    testID="simpleButtonTestID"
  >
    <Text
      style={
        Array [
          Object {
            "color": "#B3B5BD",
            "fontFamily": "RobotoMono-Medium",
            "fontSize": 16,
            "lineHeight": 21,
          },
        ]
      }
    >
      Call API
    </Text>
  </View>
</RCTSafeAreaView>
`;

Podsumowanie

Mam nadzieję, że ten artykuł przybliżył Wam, w jaki sposób można pisać podstawowe testy swojej aplikacji.

Najważniejsze zalety i funkcje testów:

  • Tworzenie testów snapshotowych wszystkich komponentów i ekranów -> ponieważ niskim kosztem można zabezpieczyć się przed wprowadzaniem przypadkowych zmian we wcześniej utworzonych częściach aplikacji, w czasie gdy pracujemy nad nowymi.

  • Tworzenie testów jednostkowych wszystkich utility funkcji -> ponieważ w łatwy sposób wywołując w teście funkcje z przykładowymi argumentami i sprawdzając, czy wynik wywołania jest zgodny z przewidywanym, możemy sprawdzić, czy funkcja działa poprawnie.

  • Tworzenie snapshotów w reakcji na interakcje użytkownika -> ponieważ możemy upewnić się, że akcje użytkownika prowadzą do przewidywanych zmian w UI.

Michał Matuła

Padawan React Native z aspiracjami na mistrza | Football, art & music

Zostaw komentarz

dziesięć + szesnaście =

Top