Jak uniknąć błędów podczas developmentu aplikacji mobilnej w React Native
Podczas skomplikowanego procesu, jakim jest development aplikacji mobilnej z użyciem React Native, każda z osób, która nad nią pracuję, może nieświadomie wprowadzić do niej błąd. W tym artykule postanowiłem zaprezentować zbiór technologii i narzędzi, pozwalających na zminimalizowanie ilości błędów, które mogą wystąpić w gotowej aplikacji.
1. Type checking
JavaScript, przy pomocy którego piszemy aplikację mobilną w React Native, jest językiem dynamicznie typowanym. W praktyce oznacza to, że takie języki nie wymagają podania typu zmiennej przy jej deklaracji.
Wadą takiego rozwiązania jest to, że gdy popełnimy błąd, to dowiemy się o nim dopiero wtedy, gdy nasz kod zostanie uruchomiony. Dlatego też warto skorzystać z type checking'u, który postaram się pokrótce przybliżyć.
1.1. PropTypes
PropTypes oryginalnie było częścią React’a. Teraz jednak funkcjonuje jako osobna biblioteka dostępna jako “prop-types” na npm’ie. Biblioteka służy do sprawdzania typów propów, jakie przekazujemy do komponentu. W przypadku, gdy podamy nieprawidłowy prop w zależności od naszego edytora kodu powinien pojawić się stosowny błąd.
Przykładowe użycie biblioteki prop-types
:
import * as React from 'react';
import {TouchableOpacity, Text} from 'react-native';
import PropTypes from 'prop-types';
const Button = ({label, onPress}) => (
<TouchableOpacity>
<Text>{label}</Text>
</TouchableOpacity>
);
Button.propTypes = {
label: PropTypes.string.isRequired,
onPress: PropTypes.func
}
W powyższym przykładzie nasz komponent może otrzymać 2 propy:
label
- ciąg znaków (wymagany)onPress
- funkcja (niewymagana)
1.2. TypeScript
TypeScript jest statycznie typowanym językiem programowania stworzonym przez Microsoft i o otwartym źródle. Dużą zaletą jest możliwość stopniowej migracji projektu z JavaScript na TypeScript. Język ten jest obsługiwany przez najpopularniejsze edytory kodu. Przy pomocy kompilatora lub narzędzia Babel kod TypeScript przekształcany jest do kodu JavaScript przy wcześniejszym sprawdzeniu typów.
Oto kod tego samego komponentu, ale napisany już w TypeScript.
import * as React from 'react';
import {TouchableOpacity, Text} from 'react-native';
type ButtonProps = {
label: string;
onPress?: () => void;
};
const Button: React.VFC<ButtonProps> = ({label, onPress}) => (
<TouchableOpacity onPress={onPress}>
<Text>{label}</Text>
</TouchableOpacity>
);
Moim zdaniem, to właśnie TypeScript jest znacznie lepszą odpowiedzią na problem, jakim jest Type checking. To właśnie z jego pomocą piszemy w Polcode aplikacje React Native i osobiście jestem bardzo z tego języka zadowolony.
2. Debugowanie
Debugowanie jest istotnym procesem, który ma miejsce podczas developmentu aplikacji. Polega on na znalezieniu i wyeliminowaniu rozmaitych błędów. Poniżej chciałbym przedstawić narzędzia, które znacznie ułatwiają ten proces.
2.1. Wbudowane narzędzia
React Native oferuje nam wbudowane narzędzia do debugowania naszej aplikacji. Aby otworzyć menu dewelopera, należy potrząsnąć nasze urządzenie, lub użyć także skrótów klawiszowych: Cmd + D dla symulatora iOS, Cmd + M dla emulatora Android na macOS, lub Ctrl + M dla systemów Windows i Linux. Jeżeli zobaczymy menu, takie jak poniżej, to możemy zacząć korzystać z narzędzi.
2.1.1 Remote JS Debugging
Po wybraniu tej opcji kod naszej aplikacji będzie wykonywany na naszym komputerze, zamiast na urządzeniu mobilnym lub symulatorze. Zostanie otwarta przeglądarka Chrome, gdzie możemy debugować naszą apkę tak jakby to była aplikacja webowa.
Wadą tego sposobu jest jednak to, że kod nie jest wykonywany na smartfonie, czy symulatorze, a na naszym komputerze, co skutkuje spadkiem wydajności.
2.1.2. React Native Inspector
React Native Inspector służy do wyświetlania informacji o elementach interfejsu, czy też ruchu sieciowym. Po przyciśnięciu elementu możemy zobaczyć przykładowe style jego lub jego rodziców.
2.2. Flipper
Moim najnowszym odkryciem w temacie debuggingu jest Flipper. Ten debugger od Facebooka łączy w sobie wszystkie funkcjonalności wbudowanych narzędzi, a także pozwala rozszerzyć swoje możliwości poprzez pluginy.
Preferuję korzystać z Flippera ze względu na dwa (moim zdaniem) killer features:
Target mode
- pozwala wybrać element poprzez tapnięcie go na ekranie i podejrzenie informacji o nimQuick edits
- umożliwia stylowanie „na żywo” zupełnie tak samo, jak gdybyśmy korzystali z Inspectora w Google Chrome.
Strona domowa: https://fbflipper.com/
3. Testy automatyczne
Testy automatyczne pokrótce oznaczają użycie oprogramowania do sprawdzenia jakości innego kodu. Do testowania aplikacji React Native przyda się nam kilka bibliotek.
Wymarzony widok każdego developera ?
3.1. Jest
Jest to framework służący do testowania aplikacji napisanych w JavaScript i TypeScript. Jego zaletą jest minimalny czas wymagany na konfigurację, wbudowana obsługa code coverage oraz wsparcie dla mocków.
Oto przykład użycia Jest:
const multiply = (a, b) => a * b;
test('5 times 5 equal 25', () => {
expect(multiply(5, 5)).toEqual(25);
});
Sprawdzamy tutaj, czy nasza funkcja multiply
zwraca poprawny wynik mnożenia.
Strona domowa projektu: https://jestjs.io/en/
3.2. Enzyme
Enzyme to narzędzie JavaScript, za którym stoi Airbnb. Pozwala ono na pracę z komponentami React i React Native. Wsparcie dla React Native jest jednak dosyć ograniczone i nie pozwala w pełni na przetestowanie aplikacji.
import * as React from 'react';
import {TouchableOpacity, Text} from 'react-native';
type ButtonProps = {
label: string;
onPress?: () => void;
};
const Button: React.VFC<ButtonProps> = ({label, onPress}) => (
<TouchableOpacity onPress={onPress}>
<Text>{label}</Text>
</TouchableOpacity>
);
export default Button;
Button.tsx
import * as React from 'react';
import {shallow} from 'enzyme';
import Button from './Button.tsx';
describe('Button component', () => {
it('should match the snapshot', () => {
const wrapper = shallow(<Button label={'Press me!'} />);
expect(wrapper).toMatchSnapshot();
});
});
Button.test.tsx
Powyższy przykład przedstawia test snapshotowy dla naszego komponentu Button.tsx
. Zadaniem testów snapshotowych jest sprawdzenie, czy przez przypadek nie zostały wprowadzone zmiany do UI. Wywołanie funkcji toMatchSnapshot
powoduje wygenerowanie snapshotu i porównanie go do snapshotu zapisanego w pliku równolegle z Button.test.tsx
. Snapshoty można nadpisać, odpalając testy z flagą -u
.
Strona domowa projektu: https://enzymejs.github.io/enzyme/
3.3 React Native Testing Library
React Native Testing Library to biblioteka służąca do testowania komponentów React Native, która nakłania do używania dobrych praktyk. Korzystam z niej w wielu projektach i jestem bardzo zadowolony.
Oto przykładowy test komponentu Button.tsx
z użyciem RNTL:
import * as React from 'react';
import {render} from '@testing-library/react-native';
import Button from './Button.tsx';
describe('Button component', () => {
it('should match the snapshot', () => {
const {toJSON} = render(<Button label={'Press me!'} />);
expect(toJSON()).toMatchSnapshot();
});
});
Button.test.tsx
Link do dokumentacji: https://callstack.github.io/react-native-testing-library/
3.4 Testowanie redux reducerów
Jeżeli w naszej apce korzystamy z Reduxa, aby uniknąć błędów, dobrze byłoby napisać testy do redux reducerów. Jako, że reducer to pure function, to nie jest to skomplikowane i w zasadzie ogranicza się do przetestowania wartości, jaką otrzymujemy jako wynik wywołania funkcji.
Zakładając, że nasz reducer wygląda następująco:
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
reducer.ts
To możemy napisać do niego testy w następujący sposób:
import reducer from './reducer';
describe('counter reducer', () => {
it('should return the default state', () => {
expect(reducer(undefined, {})).toEqual(0);
});
it('should handle INCREMENT action', () => {
expect(reducer(1, {type: 'INCREMENT'})).toEqual(2);
});
it('should handle DECREMENT action', () => {
expect(reducer(1, {type: 'INCREMENT'})).toEqual(0);
});
});
reducer.test.ts
W powyższym przykładzie mamy 3 test case’y. Pierwszy z nich sprawdza, czy nasz reducer zwraca poprawny stan przy inicjalizacji (w naszym przypadku jest to liczba 0).
Kolejne 2 przypadki obejmują już poszczególne akcje (u nas INCREMENT
i DECREMENT
) - sprawdzają, czy stan reducera zmienia się zgodnie z założeniem.
3.5 Testowanie redux sag
Bardzo fajną biblioteką, która współgra z Reduxem jest Redux Saga. Pomaga nam ona zoptymalizować obsługę asynchronicznych zadań. Zadania te nie muszą się ograniczać tylko i wyłącznie do zapytań do API - biblioteka ta jest bardzo rozbudowana. W poniższym przykładzie przedstawię sagę wykonującą zapytanie do API wraz z testami.
import {select} from 'redux-saga/effects';
import axios from 'axios';
export function* getProfile() {
try {
const accessToken = yield select(state => state.auth.accessToken);
const resp = yield axios.request({
method: 'GET',
url: '/profile',
headers: {
Authorization: `Bearer ${accessToken}`
}
});
yield put(actions.getProfileSuccess(resp.data));
} catch(err) {
yield put(actions.getProfileFailure(err));
}
}
saga.ts
Powyższa saga ma za zadanie pobranie access tokenu z Redux Store i wykonanie requesta przy pomocy axios do naszego API, a następnie zdispatchowanie akcji z payload, w którym są informacje o naszym profilu. W przypadku niepowodzenia lub jakiegokolwiek innego błędu zostanie wysłana akcja getProfileFailure
.
Oto jak można przetestować powyższą sagę:
```
import {runSaga} from 'redux-saga';
import axios from 'axios';
import {getProfile} from './saga';
import {Action} from 'redux';
describe('getProfile saga', () => {
it('should succeed', async () => {
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const profile = {name: 'John', surname: 'Doe'};
const axiosSpy = jest.mock(axios, 'spy').mockResolvedValue({
status: 200,
data: profile
});
const dispatched = [];
await runSaga({
dispatch: (action: Action) => dispatched.push(action),
getState: () => ({
auth: {
accessToken
}
})
}, getProfile).toPromise();
expect(axiosSpy).toHaveBeenCalledWith({
method: 'GET',
url: '/profile',
headers: {
Authorization: `Bearer ${accessToken}`
}
});
expect(dispatched).toContainEqual(actions.getProfileSuccess(profile));
});
it('should handle failure', async () => {
const accessToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6I
kpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_ad
Qssw5c';
const error = Error('fake error');
const axiosSpy = jest.mock(axios, 'spy').mockRejectedValue(error);
const dispatched = [];
await runSaga({
dispatch: (action: Action) => dispatched.push(action),
getState: () => ({
auth: {
accessToken
}
})
}, getProfile).toPromise();
expect(axiosSpy).toHaveBeenCalledWith({
method: 'GET',
url: '/profile',
headers: {
Authorization: `Bearer ${accessToken}`
}
});
expect(dispatched).toContainEqual(actions.getProfileFailure(error));
});
});
saga.test.ts
W tym przypadku mamy drugi test case: powodzenie i wystąpienie błędu.
Na początku testu definiujemy nasz Access Token (nie jest prawdziwy, nie ma potrzeby sprawdzać ?), a także przygotowujemy mock. Jego zadaniem jest symulacja odpowiedzi z naszego API, a sam request nie zostaje wywołany.
Mając przygotowane wszystkie wartości testowe, a także mocki możemy uruchomić naszą sagę. Jako pierwszy parametr funkcji runSaga
podajemy zestaw opcji. W naszym przypadku są to:
dispatch
- funkcja, która będzie wywoływana każdorazowo przy zdispatchowaniu akcji (w naszym przypadku dodajemy każdą zdispatchowaną akcję do tablicydispatched
)getState
- funkcja zwracająca stan Redux Store.
Drugim parametrem jest już tylko nasza saga, którą chcemy uruchomić. W pierwszym test case po jej uruchomieniu sprawdzamy, czy funkcja axios.request
została wywołana z odpowiednimi argumentami, a także czy akcja getProfileSuccess
została zdispatchowana z danymi, które zwróciliśmy w mocku.
Z kolei w drugim przypadku sprawdzamy również, czy funkcja axios.request
została wykonana z zadanymi opcjami, natomiast tutaj sprawdzamy, czy akcja getProfileFailure
została wysłana do Redux Store z mockowanym błędem.
4. Podsumowanie
Podsumowując, za 3 filary pozwalające uniknąć najprostszych błędów w aplikacji mobilnej pisanej z React Native uważam type checking, debugging i testy automatyczne. To właśnie te narzędzia pozwalają nam na uzyskanie informacji o błędach w aplikacji ich szybkie zlokalizowanie oraz poprawienie.