Niech pierwszy rzuci kamieniem, kto nigdy nie testował na produkcji.

Niech pierwszy rzuci kamieniem, kto nigdy nie testował na produkcji.

Środa, 5 sierpnia 2020, będzie na długo zapamiętanym dniem wśród pracowników mBanku. Nikt nie przewidział ogromnego obciążenia systemów banku spowodowanego falą logowań Użytkowników, którzy otrzymali dziwnie wyglądające powiadomienia z aplikacji. Jak się później okazało – były to powiadomienia testowe, niechcący wysłane do wszystkich Klientów banku. Skutkiem pomyłki był niedziałający przez kilka godzin system bankowości oraz niezliczone memy o testowaniu na produkcji i komentarze o tym, że na produkcji kodu się nie testuje. W dzisiejszym wpisie zamierzam – trochę pod prąd – opowiedzieć Ci o tym, jak (i co) testować na produkcji, oraz wyjaśnię, dlaczego nie wszystko można sprawdzić testami jednostkowymi (unit testing) i integracyjnymi.

Testy – jednostkowe, integracyjne, automatyczne i co jeszcze?

Tworzenie oprogramowania wbrew pozorom nie ogranicza się tylko i wyłącznie do pisania kodu. W zasadzie kod częściej się czyta, niż pisze od nowa. Warto mieć to na uwadze już na samym początku pracy – nazywając zmienne i funkcje w odpowiedni, zrozumiały sposób; dodając komentarze w miejscach bardziej skomplikowanych czy też starając się utrzymać w miarę prostą implementację. Praca nad oprogramowaniem rzadko kiedy jest miejscem, by chwalić się umiejętnością zapisania konkretnego warunku niezwykle krótko, choć niezrozumiale dla innych.
 
Ponieważ raz napisane oprogramowanie dopiero rozpoczyna swój cykl życia, dodajemy testy jednostkowe. W ten sposób programista ma możliwość sprawdzenia zaimplementowanej logiki (weryfikuje potencjalne literówki jak np. zamianę znaku < na >, jak i bardziej skomplikowane błędy w działaniu programu). Co więcej, testy jednostkowe przydatne są przy kolejnej iteracji, kiedy w programie wprowadzane są zmiany. Dzięki temu można łatwo sprawdzić, że nowy kod nie psuje tego już wcześniej napisanego.
 
W wypadku bardziej skomplikowanych systemów – składających się z więcej niż jednego komponentu – z pomocą przychodzą testy integracyjne, czyli takie, które sprawdzają jak każdy z elementów współgra z innymi. Załóżmy, że pracujesz nad aplikacją, która wyświetla informacje otrzymane z serwera. W ramach testu integracyjnego możesz sprawdzić, czy klient (Twoja aplikacja) poprawnie komunikuje się z serwerem oraz że dane wysłane przez serwer są tymi, które aplikacja wyświetla. Takie testy można wykonać ręcznie bądź je zautomatyzować.
 
Ponadto, w orężu zespołów programistycznych znaleźć też można automatyczne testy przeglądarkowe, które pozwalają na zdefiniowanie akcji Użytkownika (np. kliknięcie przycisku na stronie) i sprawdzenie rezultatu (np. wyświetlenie komunikatu o błędzie). Dzięki temu można łatwo wyłapać błędy w UI (user interface) związane ze zmianami wyglądu aplikacji bądź uaktualnieniu wykorzystywanego API.
 
Ważnym graczem w procesie tworzenia oprogramowania są również zespoły testerów / QA (Quality Assurance), którzy testują kolejne wersje developerskie aplikacji w poszukiwaniu trudnych do wyłapania błędów, organizują tzw. “bug bashe”, czyli sesje, w których trakcie starają się aplikację “popsuć”, a następnie opracowują odpowiednie raporty dla programistów i wspierają w automatyzowaniu testów.
 

Czy testy na produkcji to zło?

Jak w wielu wypadkach, tak i teraz napiszę – to zależy. Testowanie aplikacji na produkcji samo w sobie nie jest złą praktyką, jednak należy dążyć do ograniczenia sytuacji, w których jest to konieczne. Warto więc wykorzystywać jak najwięcej narzędzi pozwalających zintegrować testy z procesem pisania kodu – np. wdrażając praktyki CI/CD (continuous integration / continuous delivery) oraz kładąc duży nacisk na pisanie testów jednostkowych. W niektórych firmach, czy projektach open sourceowych, stosuje się zasadę, że każdy element systemu powinien być “pokryty testami” w określonym procencie,  żeby zapewnić jak największą poprawność. Warto również inwestować w rozwój środowiska testowego, które pracownicy mogą “psuć” do woli testując swoje zmiany.
 
Posiadanie odrębnego środowiska testowego z aktualnymi wersjami wszystkim komponentów jest jednak bardzo drogie. Nie chodzi tu wbrew pozorom tylko o koszt pieniężny. Zapewnienie odpowiedniej izolacji od środowiska produkcyjnego często wiąże się z dodatkowym obciążeniem dla adminów, czy programistów, a w kodzie zaczynają pojawiać się kwiatki w stylu: 
 
if env.isProduction {
  // do something
} else {
  // do something different
}
 
Ten dodatkowy koszt to jedna z najczęstszych wymówek, dlaczego firma nie posiada środowiska testowego.
 

Kiedy testowanie na produkcji to konieczność i jak robić to dobrze?

Czasem zwyczajnie nie da się uniknąć konieczności testowania systemu na produkcji. Niezależnie od tego, ile środków i czasu zespół poświęci w opracowanie systemu testowego, produkcja (czyli prawdziwa wersja systemu dostępna dla Użytkowników), to jednak produkcja. 

Testy A/B

Żaden – nawet najlepszy – zespół QA nie da Ci takiej wartości jak opinie faktycznych Użytkowników Twojego systemu! Użytkowników jest po prostu więcej i korzystają oni z Twojego systemu w przeróżny sposób (czasem taki, który nie śnił się nawet filozofom!). Ponieważ to dla nich powstaje dany produkt, ich opinia jest tu najważniejsza. I wcale nie chodzi o wysyłanie Użytkownikom formularza z pytaniami – Użytkownicy głosują poprzez swoją akcję. 
 
Metoda testów A/B pozwala na odpalenie dwóch lekko różniących się wersji aplikacji, a następnie analizę ruchu i działań Użytkowników. Dla przykładu, jeśli pracujesz nad zmianą wyglądu strony logowania, możesz skorzystać z tej metody, a następnie sprawdzić, która wersja – A czy B – była wygodniejsza dla Użytkowników. Ocenisz to np. poprzez analizę liczby niepoprawnych zalogowań, wyjść ze strony, dodatkowych kliknięć w inne elementy formularza. 

Gradual rollout”, czyli stopniowe wdrażanie zmian

Nie wszystkie zmiany w systemie powinny być wdrażane w ciągu minuty. Niekiedy należy stopniowo otwierać się na większe grupy Użytkowników, aby uniknąć nieprzewidzianych problemów. To trochę bardziej rozbudowana wersja testów A/B.
 
Załóżmy, że do swojego systemu dodajesz zupełnie nową funkcjonalność, nowy serwer, nowe API – nawet najbardziej rozbudowany system CI/CD i setki testów jednostkowych nie jest w stanie dać Ci 100% pewności, że system będzie działać, jak trzeba. Dlatego też warto wprowadzać tę zmianę stopniowo – np. dzieląc Użytkowników na mniejsze grupy, tzw. “populacje”. W pierwszym etapie wdrożenia tylko 1% Użytkowników dostaje dostęp do nowych funkcjonalności. Dzięki temu jesteś w stanie sprawdzić, czy nie pojawiają się jakieś oczywiste błędy (np. Użytkownicy otrzymują błąd 404 w przeglądarce) i w razie czego szybko wycofać zmianę. Choć wtedy faktycznie Twoje produkcyjne środowisko nie zadziałało poprawnie, to jednak ograniczasz liczbę osób, które faktycznie będą dotknięte tym problemem. W kolejnych etapach wdrożenia stopniowo zwiększasz rozmiar populacji, by ostatecznie “otworzyć bramkę” dla wszystkich Użytkowników. W ten sposób dajesz sobie również czas na sprawdzenie obciążenia nowych (i starych!) serwerów i zwiększenia mocy przerobowych, jeśli trzeba, zanim system faktycznie padnie! 
 
Dobrym przykładem tego typu działania jest wdrażanie nowego wyglądu przez serwis Facebook. Pewnie zwróciłaś uwagę na lekkie zamieszanie wśród Użytkowników – niektórzy od dawna mieli już dostęp do nowego wyglądu, podczas gdy inni Użytkownicy zmuszeni byli czekać przez kilka miesięcy. Można to zaobserwować nie tylko między znajomymi, ale też między krajami (Użytkownicy z niektórych państw mają dostęp do nowych funkcjonalności znacznie wcześniej niż inni).
 

“Kontrolowany chaos”

DRT (“disaster readiness test”) to metoda wprowadzania chaosu w systemie w sposób kontrolowany. Co się stanie, kiedy jedna z replik bazy danych straci dostęp do sieci? Co się stanie, kiedy padnie jeden z dysków twardych serwera? Jak system się zachowa, jeśli serwer nie będzie mógł połączyć się z innymi komponentami systemu przez 5 minut? W teorii – odpowiedź jest znana, wystarczy w końcu przeczytać kod i zastanowić się nad poszczególnymi ifami. W teorii – te sytuacje były już sprawdzone w środowisku testowym. Nigdy jednak nie ma 100% pewności. Poza tym nie wszystko da się przetestować w środowisku testowym ani zapewnić idealnego odzwierciedlenia ruchu i obciążenia systemu wygenerowanego przez prawdziwych Użytkowników. Dlatego też w kontrolowany sposób wprowadza się trochę chaosu – wymuszając poszczególne błędy systemu. 
 
Kontrolowany chaos” pozwala na sprawdzenie, jak system zachowa się w sytuacji stresowej przy normalnym (lub zwiększonym) obciążeniu. Całość odbywa się w komfortowych warunkach, kiedy większość pracowników jest dostępna i poinformowana o potencjalnych zagrożeniach. Dzięki temu – jeśli okaże się, że system nie działa, jak powinien – zespół będzie mógł odpowiednio szybko zareagować i w miarę możliwości naprawić problem, zanim faktycznie odbije się on na Użytkownikach. 
 
DRT polegają właśnie na symulowaniu problemów, które mają niezerową szansę wystąpienia na produkcji – w końcu dyski twarde mają określony czas życia, problemy z siecią nie są wcale tak rzadkie, i w sumie to nigdy nie wiadomo jaki chochlik zawita w naszym systemie. Nie każdy tego typu test kończy się sukcesem… jednak dzięki temu można zidentyfikować potencjalne problemy i zaplanować odpowiednie działania, które pozwolą zapobiegać ich wystąpieniu w przyszłości. Metoda ta pozwala również na określenie limitów systemu – np. maksymalnego obciążenia serwerów. 
 
Warto przy tym pamiętać, że nie tylko programiści powinni zostać powiadomieni o takim teście. Jeśli istnieją przesłanki, że test może odbić się na Użytkownikach, warto ich powiadomić wcześniej – np. poprzez umieszczenie odpowiedniego banera na stronie. 

Integracje z zewnętrznymi systemami

Wspomniane już testy integracyjne są świetnym rozwiązaniem, by sprawdzić, jak poszczególny komponenty systemu współgrają ze sobą. Co jednak w sytuacji, kiedy nasz system komunikuje się z jakimś zewnętrznym serwisem, nad którym nie mamy kontroli? Niestety, niewiele serwisów oferuje swoim Klientom dostęp do “testowej” wersji – jedyną opcją jest więc testowanie integracji z ich produkcyjną wersją. Takie rzeczy jak zbyt długie lub przerwane połączenie, niepoprawne dane, czy błędna identyfikacja na pewno zasługują na Twoją uwagę.
 
Wykorzystywać można do tego osobne, testowe konta Użytkowników; specjalne polecenia i dodatkowe narzędzia lub też wspomnianą metodę “gradual rollout” z odpowiednimi ustawieniami populacji. Metodę testowania takiej integracji należy dobierać tak, by dostarczyła, jak najwięcej informacji zwrotnej, a przy tym była bezpieczna dla prawdziwych Użytkowników. 
 

Wpadki się zdarzają!

Pamiętaj, że kod tworzą i testują ludzie, a ludzie nieomylni nie istnieją. Dlatego też wszyscy powinniśmy sobie uświadomić, że wpadki się po prostu zdarzają, nawet w tak poważnych instytucjach, jak banki. Jednak, zamiast załamywać ręce i chować się w kącie, warto wykorzystać każdą z nich jako lekcję, z której można wyciągnąć wniosku na przyszłość. 
 
W wypadku mBanku taką lekcją może być praca nad izolacją systemu testowego od produkcyjnego i lepsza kontrola doboru populacji do testów. Warto też pewnie wprowadzić odpowiednie zabezpieczenia, które wymuszą dodatkowe potwierdzenie wszelkich manualnych działań na systemie produkcyjnym. 
 
mBank nie jest jednak jedyną firmą, której zdarzyła się tego typu poważna wpadka. 
W 2014 roku moja koleżanka z pracy niechcący usunęła produkcyjne bazy danych Dropboksa, o czym opowiedziała we wpisie na blogu. W 2017 roku S3 (jeden z serwisów oferowanych w ramach usług Amazon AWS) padł kompletnie z powodu pomyłki pracowników, powodując problemy nie tylko wewnątrz firmy, ale też uniemożliwiając działanie setek serwisów internetowych. 
 
Ważne, żebyśmy wszyscy umieli wyciągnąć z tych wpadek coś dla siebie. Czasem będą to pomysły na usprawnienie systemu, a czasem po prostu krótka lekcja empatii.