DevCezz

Programistyczny blog dla Ciebie

Co dają nam testy mutacyjne?
Testy

Co nam dają testy mutacyjne?

Po ostatnim wpisie na temat Property Based Testing chciałbym pozostać w sferze testowania kodu. Z tego powodu dzisiaj zajmiemy się ideą stojącą za testami mutacyjnymi. Zastanawiałem się ostatnio jaką wartość dodaną mogą one wnieść do naszego projektu. Okazuje się, że dzięki nim mamy możliwość weryfikacji czy nasze testy są w stanie wyłapać losowe modyfikacje zachodzące w kodzie. Odbywa się to poprzez wprowadzenie mutacji, czyli celowych błędów w kodzie, i sprawdzeniu czy istniejąca sieć testów jest na nie odporna.

Fundamentalna metryka Mutation Coverage

Do weryfikacji skuteczności testów najczęściej wykorzystywane są metryki takie jak Code Coverage albo Branch Coverage. Jednak dla testów mutacyjnych powstała specjalna metryka o nazwie Mutation Coverage. Aby ją wyjaśnić trzeba wprowadzić pojęcie „mutanta”.

Mutant to nic innego jak zmiana w kodzie polegają na np. podmianie znaku arytmetycznego (+ na - albo / na *) czy też operatora logicznego (== na != albo <= na >). Gdy mutant zostanie wprowadzony do kodu to może on zostać zabity przez siatkę testów albo z niej uciec. Ten drugi przypadek jest niekorzystny, ponieważ oznacza on, że stworzona przez nas ochrona przed błędami jest nieskuteczna. Ja to tłumaczę sobie tak, że silnik testów mutacyjnych to taka osoba trzecia, która nieumyślnie może wprowadzić zmianę logiczną w kodzie produkcyjnym. Gdy testy nie są na nią gotowe to po prostu tego nie wyłapią, a usterka prawdopodobnie trafi do aplikacji klienckiej.

Mutation Coverage więc to nic innego jak metryka określająca stosunek ilości mutantów, których udało się wyłapać do całości przeprowadzonych przypadków. Jeśli ilość pozytywnych przypadków wyniosła 17, a ilość wprowadzonych modyfikacji jest równa 25 to Mutation Coverage jest na poziomie 68%.

Grafika wyjaśnia więcej niż 1000 słów

Jeśli chodzi o rezultat przeprowadzonych testów to musiałem się chwilę zastanowić, który zielony kolor w wyniku jest dla nas oczekiwany. Zrobiłem sobie mentalną próbę zamiany jednego warunku logicznego i przeszedłem po napisanych testach. Z tego eksperymentu wykreowała się poniższa grafika, która mam nadzieję, że jeszcze bardziej wyjaśni kwestię żywotności mutantów.

Stworzony mutant (zmiana w kodzie) będzie chciał uciec (nie uruchomić żadnego alarmu wśród testów). Jeśli testy będą zielone to jak najbardziej mu się to udało i w raporcie ta mutacja zapali się na czerwono. Jeśli natomiast chociaż jeden test stanie się czerwony to będzie to oznaczało, że nieprawidłowość w kodzie została wyłapana. Wtedy taki mutant ginie, a raport podkreśla nam taką zmianę na zielono. W skrócie zielone testy są złe przy mutacji, bo nie wyłapują zmiany co podkreślane jest czerwonym kolorem w raporcie.

Po przyswojeniu tej krótkiej teorii czas przejść do praktyki. W kolejnej sekcji napiszemy kawałek kodu, który następnie pokryjemy testami i poddamy testom mutacyjnym. W tym celu wykorzystamy bibliotekę o nazwie PITest.

Piszemy kod produkcyjny

Załóżmy, że piszemy aplikację, która będzie sprawdzała jaką ocenę powinien dostać student na podstawie procentów jakie uzyskał z kolokwium. Oczywiście jeśli ma on status obiboka to z automatu będzie dostawał dwóję. Natomiast jeśli jest to wybitny student to jego ocena ulegnie podwyższeniu o pół stopnia. Progi będą przedstawiać się następująco:

  • 2,0 – x < 50%
  • 3,0 – 50% <= x < 60%
  • 3,5 – 60% <= x < 70%
  • 4,0 – 70% <= x < 80%
  • 4,5 – 80% <= x < 95%
  • 5,0 – x >= 95%

Skoro mamy już wszystkie wymagania to zabierzmy się do kodowania.

package pl.devcezz.mutation;

public class GradeCalculator {

    enum Grade {
        TWO(2.0),
        THREE(3.0),
        THREE_AND_HALF(3.5),
        FOUR(4.0),
        FOUR_AND_HALF(4.5),
        FIVE(5.0);

        private final double value;

        Grade(double value) {
            this.value = value;
        }

        double getValue() {
            return value;
        }

        Grade increaseByHalf() {
            return switch (this) {
                case TWO -> THREE;
                case THREE -> THREE_AND_HALF;
                case THREE_AND_HALF -> FOUR;
                case FOUR -> FOUR_AND_HALF;
                case FOUR_AND_HALF -> FIVE;
                default -> FIVE;
            };
        }
    }

    public Grade calculate(double percentage, Student student) {
        if (percentage < 0 || percentage > 100) {
            throw new IllegalArgumentException(
                    "percentage must be value between 0 and 100");
        }

        if (student.isSlacker()) {
            return Grade.TWO;
        }

        Grade grade = resolveGrade(percentage);

        if (student.isOutstanding()) {
            return grade.increaseByHalf();
        }
        return grade;
    }

    private Grade resolveGrade(double percentage) {
        if (percentage < 50) {
            return Grade.TWO;
        } else if (percentage < 60) {
            return Grade.THREE;
        } else if (percentage < 70) {
            return Grade.THREE_AND_HALF;
        } else if (percentage < 80) {
            return Grade.FOUR;
        } else if (percentage < 95) {
            return Grade.FOUR_AND_HALF;
        } else {
            return Grade.FIVE;
        }
    }
}

Przyszła pora na stworzenie siatki bezpieczeństwa w postaci testów jednostkowych. Pierwszym przypadkiem będzie sprawdzenie czy faktycznie student obibok dostanie dwóję nawet jak będzie miał 100% z testu. Następnie chcemy, aby zwykły student dostał to na co zasłużył. Skoro miał 50% to powinien zobaczyć 3.0 na kolokwium. W sytuacji, gdy wybitny student nie otrzyma maksymalnej oceny to jego stopień i tak powinien zostać podwyższony. Więc gdy z kolokwium wyszłoby, że powinien otrzymać 4.5 to i jego ostatecznym wynikiem będzie piątka. Dwa ostatnie testy będą sprawdzeniem czy podany zakres procentów jest akceptowalny.

package pl.devcezz.mutation;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class GradeCalculatorTest {

    GradeCalculator gradeCalculator = new GradeCalculator();

    @Test
    void shouldGetTwoIfSlacker() {
        Student slacker = aStudent(Student.Status.SLACKER);

        GradeCalculator.Grade grade = gradeCalculator.calculate(100, slacker);

        assertEquals(GradeCalculator.Grade.TWO, grade);
    }

    @Test
    void shouldGetThreeIfPercentageIsFifty() {
        Student regular = aStudent(Student.Status.REGULAR);

        GradeCalculator.Grade grade = gradeCalculator.calculate(50, regular);

        assertEquals(GradeCalculator.Grade.THREE, grade);
    }

    @Test
    void shouldGetFiveIfPercentageIsEightyAndIsOutstanding() {
        Student outstanding = aStudent(Student.Status.OUTSTANDING);

        GradeCalculator.Grade grade = gradeCalculator.calculate(80, outstanding);

        assertEquals(GradeCalculator.Grade.FIVE, grade);
    }

    @Test
    void shouldFailWhenPercentageIsBelowZero() {
        Student regular = aStudent(Student.Status.REGULAR);

        assertThrows(IllegalArgumentException.class, () -> gradeCalculator.calculate(-23, regular));
    }

    @Test
    void shouldFailWhenPercentageIsAboveOneHundred() {
        Student regular = aStudent(Student.Status.REGULAR);

        assertThrows(IllegalArgumentException.class, () -> gradeCalculator.calculate(123, regular));
    }

    Student aStudent(Student.Status status) {
        return new Student(status);
    }
}

Testy przechodzą i wydaje się, że rozpatrzyliśmy wszystkie możliwe przypadki. Jednak co na to powiedzą testy mutacyjne?

Podpinamy silnik testów mutacyjnych PITest

W naszym projekcie nie musimy jakoś wybitnie starać się o konfigurację PITest. Wystarczy, że dodamy go do listy pluginów Mavena czy Gradle. Ja oczywiście korzystam z pierwszego rozwiązania, więc plik pom.xml będzie wyglądał podobnie do tego poniżej.

    ...

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>RELEASE</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>0.15</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.pitest</groupId>
                <artifactId>pitest-maven</artifactId>
                <version>1.7.6</version>
            </plugin>
        </plugins>
    </build>
</project>

Teraz wystarczy wpisać magiczną formułę mvn test-compile org.pitest:pitest-maven:mutationCoverage do wiersza poleceń i czekać na rezultat prac PITest. Po chwili oczekiwania dostaniemy wynik działania biblioteki.

$ mvn test-compile org.pitest:pitest-maven:mutationCoverage
...
================================================================================
- Statistics
================================================================================
>> Line Coverage: 36/46 (78%)
>> Generated 31 mutations Killed 21 (68%)
>> Mutations with no coverage 5. Test strength 81%
>> Ran 38 tests (1.23 tests per mutation)
Enhanced functionality available at https://www.arcmutate.com/
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  11.390 s
[INFO] Finished at: 2022-05-02T12:33:06+02:00
[INFO] ------------------------------------------------------------------------

Pokrycie kodu testami wyniosło 78%, natomiast ilość wytworzonych mutantów to 31, gdzie 21 zostało zabitych (68% skuteczności). Fajną cechą PITest jest, że dokładniejszy raport z testów otrzymujemy w formie graficznej.

Graficzny raport z testów mutacyjnych

Wystarczy, że wejdziemy do ścieżki target/pit-reports, gdzie umieszczane są katalogi nazwane timestampem. Jeśli otworzymy ten przed chwilą wygenerowany to ujrzymy plik index.html. Gdy klikniemy w niego to naszym oczom ukaże się prosta strona internetowa z nagłówkiem „Pit Test Coverage Report„. Tutaj również znajdziemy informację o metrykach dla poszczególnych pakietów i całego projektu. Nas jednak najbardziej interesuje to co zobaczymy dla klasy GradeCalculator.

Jeśli się w niego wczytamy to zauważymy jakie sposoby generowania mutantów zostały zastosowane. Również dostępna jest informacja o tym w których liniach wprowadzono zmiany oraz dla jakich przypadków mutant przeżył.

Oczywiście jak ze wszystkim trzeba zachować zdrowy rozsądek i przeanalizować ten raport pod kątem swojego projektu. Być może dla niektórych przypadków warto byłoby zastosować Property Based Testing, aby powstrzymać rozprzestrzenianie się mutantów?

Podsumowanie

Mam nadzieję, że to krótkie wprowadzenie do świata testów mutacyjnych dało Ci pogląd na ich działanie oraz możliwości. Oczywiście ich idea jest niezależna od technologii i w innych językach istnieją odpowiedniki biblioteki PITest. Dla mnie osobiście to podejście do testowania jest ciekawe i może stanowić uzupełnienie dla sieci bezpieczeństwa naszej aplikacji. Jednak przy dłuższym kodzie ilość tworzonych mutantów może być tak duża, że testy na pewno pochłoną sporą ilość dostępnych zasobów. Z tego powodu należy stosować je z rozwagą. Pozytywnym aspektem jest to, że mogą rozszerzyć nasze horyzonty myślowe i być może dadzą świeże spojrzenie na napisane w aplikacji testy.

Link do GitHuba: https://github.com/DevCezz/mutation-tests-example

Podziel się tym z innymi!

ZOSTAW ODPOWIEDŹ

Twój adres e-mail nie zostanie opublikowany.