>9.1 — Wprowadzenie do testowania kodu

Napisałeś program, kompiluje się i nawet wydaje się, że działa! Co teraz?

No cóż, to zależy. Jeśli napisałeś program do jednorazowego uruchomienia i wyrzucenia, to gotowe. W tym przypadku może nie mieć znaczenia, że ​​Twój program nie będzie działał w każdym przypadku — jeśli zadziała w jednym przypadku, w którym go potrzebujesz i uruchomisz go tylko raz, to gotowe.

Jeśli Twój program jest całkowicie liniowy (nie ma warunków warunkowych, takich jak instrukcje if lub instrukcje przełącznika), nie pobiera żadnych danych wejściowych i generuje poprawną odpowiedź, to prawdopodobnie skończyłeś. W tym przypadku przetestowałeś już cały program, uruchamiając go i sprawdzając wyniki. Możesz skompilować i uruchomić program na kilku różnych systemach, aby upewnić się, że zachowuje się spójnie (jeśli nie, prawdopodobnie zrobiłeś coś, co powoduje niezdefiniowane zachowanie, które tak się składa, że ​​i tak działa w twoim początkowym systemie).

Ale najprawdopodobniej napisałeś program, który zamierzasz uruchamiać wiele razy, który używa pętli i logiki warunkowej oraz akceptuje pewnego rodzaju dane wejściowe użytkownika. Prawdopodobnie napisałeś funkcje, które mogą być ponownie użyte w innych przyszłych programach. Być może doświadczyłeś niewielkiego pełzania zakresu, podczas którego dodałeś kilka nowych możliwości, które pierwotnie nie były planowane. Może nawet masz zamiar rozpowszechniać ten program wśród innych osób (które prawdopodobnie spróbują rzeczy, o których nie pomyślałeś). W takim przypadku naprawdę powinieneś sprawdzić, czy Twój program działa tak, jak myślisz, w różnorodnych warunkach — a to wymaga proaktywnych testów.

To, że Twój program zadziałał dla jednego zestawu danych wejściowych, nie oznacza, że będzie działać poprawnie we wszystkich przypadkach.

Testowanie oprogramowania (tzw Weryfikacja oprogramowania) to proces ustalania, czy oprogramowanie faktycznie działa zgodnie z oczekiwaniami.

Wyzwanie testowania

Zanim porozmawiamy o praktycznych sposobach testowania kodu, porozmawiajmy o tym, dlaczego kompleksowe testowanie programu jest trudne.

Rozważ ten prosty program:

#include <iostream>

void compare(int x, int y)
{
    if (x > y)
        std::cout << x << " is greater than " << y << '\n'; // case 1
    else if (x < y)
        std::cout << x << " is less than " << y << '\n'; // case 2
    else
        std::cout << x << " is equal to " << y << '\n'; // case 3
}

int main()
{
    std::cout << "Enter a number: ";
    int x{};
    std::cin >> x;

    std::cout << "Enter another number: ";
    int y{};
    std::cin >> y;

    compare(x, y);

    return 0;
}

Zakładając 4-bajtową liczbę całkowitą, jawne testowanie tego programu przy każdej możliwej kombinacji danych wejściowych wymagałoby uruchomienia programu 18 446 744 073 709 551 616 (~18 kwintylionów) razy. Najwyraźniej nie jest to zadanie wykonalne!

Za każdym razem, gdy prosimy użytkownika o wprowadzenie danych lub stosujemy warunek w naszym kodzie, zwiększamy liczbę możliwych sposobów wykonania naszego programu o pewien współczynnik mnożnikowy. W przypadku wszystkich programów z wyjątkiem najprostszych jawne testowanie każdej kombinacji danych wejściowych staje się niemal natychmiast niemożliwe.

Teraz Twoja intuicja powinna Ci podpowiadać, że naprawdę nie powinieneś uruchamiać powyższego programu 18 trylionów razy, aby upewnić się, że działa. Można rozsądnie stwierdzić, że jeśli przypadek 1 sprawdza się dla jednej pary x i y wartości gdzie x > y, powinien działać dla dowolnej pary x i y gdzie x > y. Biorąc to pod uwagę, staje się jasne, że tak naprawdę wystarczy uruchomić ten program około trzy razy (raz, aby wykonać każdy z trzech przypadków w funkcji compare()), aby mieć wysoki stopień pewności, że działa zgodnie z oczekiwaniami. Istnieją inne podobne sztuczki, których możemy użyć, aby radykalnie zmniejszyć liczbę testów, które musimy wykonać, aby testowanie było wykonalne.

O metodologiach testowania można napisać wiele – właściwie moglibyśmy napisać o tym cały rozdział. Ponieważ jednak nie jest to temat specyficzny dla C++, pozostaniemy przy krótkim i nieformalnym wprowadzeniu, przedstawionym z punktu widzenia Ciebie (jako programisty) testującego własny kod. W następnych kilku podrozdziałach omówimy kilka praktycznych rzeczy, o których powinieneś pomyśleć podczas testowania swojego kodu.

Testuj swoje programy w małych fragmentach

Rozważmy producenta samochodów, który buduje niestandardowy samochód koncepcyjny. Jak myślisz, które z poniższych działań spełniają?
a) Zbuduj (lub kup) i przetestuj każdy element samochodu indywidualnie przed jego zainstalowaniem. Kiedy już udowodnisz, że element działa, zintegruj go z samochodem i przetestuj ponownie, aby upewnić się, że integracja zadziałała. Na koniec przetestuj cały samochód, aby ostatecznie potwierdzić, że wszystko wydaje się być w porządku.
b) Zbuduj samochód ze wszystkich komponentów za jednym razem, a następnie przetestuj całość po raz pierwszy na samym końcu.

Prawdopodobnie wydaje się oczywiste, że opcja a) jest lepszym wyborem. A jednak wielu nowych programistów pisze kod podobny do opcji b)!

W przypadku b), jeśli którakolwiek część samochodu nie działałaby zgodnie z oczekiwaniami, mechanik musiałby zdiagnozować cały samochód, aby ustalić, co jest nie tak – problem może leżeć gdziekolwiek. Objawy mogą mieć wiele przyczyn — na przykład brak możliwości uruchomienia samochodu z powodu uszkodzonej świecy zapłonowej, akumulatora, pompy paliwa lub czegoś innego? Prowadzi to do straty czasu na próby dokładnego określenia, gdzie występują problemy i co z nimi zrobić. A jeśli zostanie wykryty problem, konsekwencje mogą być katastrofalne – zmiana w jednym obszarze może spowodować „efekty fali” (zmiany) w wielu innych miejscach. Na przykład zbyt mała pompa paliwa może prowadzić do przeprojektowania silnika, co prowadzi do przeprojektowania ramy samochodu. W najgorszym przypadku może się okazać, że konieczne będzie przeprojektowanie dużej części samochodu, aby uwzględnić początkowo niewielki problem!

W przypadku a) firma przeprowadza testy na bieżąco. Jeśli jakikolwiek element jest uszkodzony od razu po wyjęciu z pudełka, natychmiast się o tym dowiedzą i będą mogli go naprawić/wymienić. Nic nie jest integrowane z samochodem, dopóki nie zostanie sprawdzone, że działa samodzielnie, a następnie ta część jest ponownie testowana, gdy tylko zostanie zintegrowana z samochodem. W ten sposób wszelkie nieoczekiwane problemy są wykrywane tak wcześnie, jak to możliwe, choć są to nadal drobne problemy, które można łatwo naprawić.

Zanim przystąpią do montażu całego samochodu, powinni mieć wystarczającą pewność, że samochód będzie działać — w końcu wszystkie części zostały przetestowane oddzielnie i po wstępnym zintegrowaniu. Nadal możliwe jest, że na tym etapie zostaną wykryte nieoczekiwane problemy, ale wszystkie wcześniejsze testy minimalizują to ryzyko.

Powyższa analogia odnosi się również do programów, choć z jakiegoś powodu nowi programiści często nie zdają sobie z tego sprawy. Znacznie lepiej będzie, jeśli napiszesz małe funkcje (lub klasy), a następnie natychmiast je skompilujesz i przetestujesz. W ten sposób, jeśli popełnisz błąd, będziesz wiedział, że musi to dotyczyć niewielkiej ilości kodu, który zmieniłeś od ostatniej kompilacji/testowania. Oznacza to mniej miejsc do szukania i znacznie mniej czasu spędzonego na debugowaniu.

Testowanie małej części kodu w izolacji w celu zapewnienia, że ​​„jednostka” kodu jest poprawna, nazywa się testowania jednostkowego. Każdy Test jednostkowy zaprojektowano tak, aby zapewnić prawidłowe zachowanie jednostki.

Najlepsza praktyka

Napisz swój program w małych, dobrze zdefiniowanych jednostkach (funkcjach lub klasach), często kompiluj i testuj swój kod na bieżąco.

Jeśli program jest krótki i akceptuje wprowadzanie danych przez użytkownika, wystarczające może być wypróbowanie różnych sposobów wprowadzania danych przez użytkownika. Jednak w miarę jak programy stają się coraz dłuższe, staje się to mniej wystarczające i większą wartość ma testowanie poszczególnych funkcji lub klas przed zintegrowaniem ich z resztą programu.

Jak więc możemy przetestować nasz kod w jednostkach?

Testowanie nieformalne

Jednym ze sposobów testowania kodu jest przeprowadzanie nieformalnych testów podczas pisania programu. Po napisaniu jednostki kodu (funkcji, klasy lub innego oddzielnego „pakietu” kodu) możesz napisać kod, aby przetestować właśnie dodaną jednostkę, a następnie usunąć test po jego pomyślnym zakończeniu. Jako przykład dla poniższej funkcji isLowerVowel() możesz napisać następujący kod:

#include <iostream>

// We want to test the following function
// For simplicity, we'll ignore that 'y' is sometimes counted as a vowel
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

int main()
{
    // So here's our temporary tests to validate it works
    std::cout << isLowerVowel('a') << '\n'; // temporary test code, should produce 1
    std::cout << isLowerVowel('q') << '\n'; // temporary test code, should produce 0

    return 0;
}

Jeśli wyniki wrócą jako 1 i 0, to wszystko jest gotowe. Wiesz, że Twoja funkcja działa w niektórych podstawowych przypadkach i możesz rozsądnie wywnioskować, patrząc na kod, że będzie działać w przypadkach, których nie testowałeś („e”, „i”, „o” i „u”). Możesz więc usunąć tymczasowy kod testowy i kontynuować programowanie.

Zachowywanie testów

Chociaż pisanie testów tymczasowych to szybki i łatwy sposób na przetestowanie jakiegoś kodu, nie bierze to pod uwagę faktu, że w pewnym momencie możesz chcieć przetestować ten sam kod później. Być może zmodyfikowałeś funkcję, aby dodać nową możliwość i chcesz się upewnić, że nie zepsułeś niczego, co już działało. Z tego powodu rozsądniej będzie zachować testy, aby można było je ponownie uruchomić w przyszłości. Na przykład, zamiast usuwać tymczasowy kod testowy, możesz przenieść testy do funkcji testVowel():

#include <iostream>

bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

// Not called from anywhere right now
// But here if you want to retest things later
void testVowel()
{
    std::cout << isLowerVowel('a') << '\n'; // temporary test code, should produce 1
    std::cout << isLowerVowel('q') << '\n'; // temporary test code, should produce 0
}

int main()
{
    return 0;
}

Gdy tworzysz więcej testów, możesz po prostu dodać je do testVowel() .

Automatyzacja funkcji testowych

Jeden problem z powyższą funkcją testową polega na tym, że polega ona na tym, że musisz ręcznie zweryfikować wyniki po jej uruchomieniu. Wymaga to zapamiętania, jaka była najgorsza oczekiwana odpowiedź (zakładając, że tego nie udokumentowałeś) i ręcznego porównania rzeczywistych wyników z oczekiwanymi.

Możemy zrobić lepiej, pisząc funkcję testową, która zawiera zarówno testy ORAZ oczekiwane odpowiedzi i porównuje je, abyśmy nie musieli tego robić.

#include <iostream>

bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

// returns the number of the test that failed, or 0 if all tests passed
int testVowel()
{
    if (!isLowerVowel('a')) return 1;
    if (isLowerVowel('q')) return 2;

    return 0;
}

int main()
{
    int result { testVowel() };
    if (result != 0)
        std::cout << "testVowel() test " << result << " failed.\n";
    else
        std::cout << "testVowel() tests passed.\n";

    return 0;
}

Teraz możesz wywołać testVowel() w dowolnym momencie, aby ponownie udowodnić, że niczego nie zepsułeś, a procedura testowa wystarczy całą pracę za Ciebie, zwracając sygnał „wszystko w porządku” (wartość zwracana 0) lub numer testu, który nie przeszedł pomyślnie, dzięki czemu możesz sprawdzić, dlaczego się zepsuł. Jest to szczególnie przydatne podczas cofania się i modyfikowania starego kodu, aby mieć pewność, że niczego przypadkowo nie zepsułeś!

Dla zaawansowanych czytelników

Lepszą metodą jest użycie assert, które spowoduje przerwanie działania programu i wyświetlenie komunikatu o błędzie, jeśli którykolwiek test zakończy się niepowodzeniem. Nie musimy w ten sposób tworzyć ani obsługiwać numerów przypadków testowych.

#include <cassert> // for assert
#include <cstdlib> // for std::abort
#include <iostream>

bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

// Program will halt on any failed test case
int testVowel()
{
#ifdef NDEBUG
    // If NDEBUG is defined, asserts are compiled out.
    // Since this function requires asserts to not be compiled out, we'll terminate the program if this function is called when NDEBUG is defined.
    std::cerr << "Tests run with NDEBUG defined (asserts compiled out)";
    std::abort();
#endif

    assert(isLowerVowel('a'));
    assert(isLowerVowel('e'));
    assert(isLowerVowel('i'));
    assert(isLowerVowel('o'));
    assert(isLowerVowel('u'));
    assert(!isLowerVowel('b'));
    assert(!isLowerVowel('q'));
    assert(!isLowerVowel('y'));
    assert(!isLowerVowel('z'));

    return 0;
}

int main()
{
    testVowel();

    // If we reached here, all tests must have passed
    std::cout << "All tests succeeded\n";

    return 0;
}

Omawiamy assert w lekcji 9.6 — Assert i static_assert.

Struktury testów jednostkowych

Ponieważ pisanie funkcji w celu wykonywania innych funkcji jest tak powszechne i przydatne, istnieją całe struktury (zwane frameworkami testów jednostkowych), które mają pomóc uprościć proces pisania, utrzymywania i wykonywania testów jednostkowych. Ponieważ dotyczą one oprogramowania stron trzecich, nie będziemy ich tutaj omawiać, ale należy mieć świadomość, że istnieją.

Testy integracyjne

Gdy każde z Twoich urządzeń zostanie przetestowane oddzielnie, można je zintegrować z programem i ponownie przetestować, aby upewnić się, że zostały prawidłowo zintegrowane. Nazywa się to testem integracyjnym. Testowanie integracyjne jest zwykle bardziej skomplikowane — na razie wystarczy kilkakrotne uruchomienie programu i wyrywkowe sprawdzenie zachowania zintegrowanej jednostki.

Czas quizu

Pytanie nr 1

Kiedy powinieneś rozpocząć testowanie swojego kodu?

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
120 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze