Java Developer? Przejdź na wyższy poziom wiedzy 🔥💪  Sprawdź

Team Leader? Podnieś efektywność swojego zespołu 👌 Sprawdź

Mutation Coverage – testy mutacyjne w Java

utworzone przez 12 sierpnia 2020Java, Testowanie

Testy mutacyjne to proces, w którym celowo wprowadzane są błędy w kodzie (mutacje), aby sprawdzić, czy testy nadal będą pozytywne. W takim przypadku oznacza to, że zestaw testów może być niewystarczający do pokrycia potencjalnie wprowadzonych błędów. Testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.

Mutation Coverage to metryka wskazująca na stosunek linii kodu poddanych modyfikacjom, które zostały wykryte przez testy, w stosunku do wszystkich linii kodu. Wartość na poziomie 85% (11/13) oznacza, że w 2 liniach kodu zostały wprowadzone modyfikacje, które nie zostały wykryte przez istniejący zestaw testów.

Code Coverage to metryka jakościowa, która określa, w jakim stopniu kod produkcyjny jest uruchamiany podczas wykonywania zestawu testów. Z założenia program o wyższym Code Coverage, wyrażanym w procentach, jest uruchamiany w większym zakresie, więc wnioskujemy, że skoro testy się powiodły, program zawiera mniej błędów. O tym, w jaki sposób wyznaczana jest metryka Code Coverage oraz jakie ma wady -pisałem tutaj.

Z tego artykułu dowiesz się:

  • Czym są Testy Mutacyjne i Mutation Coverage.
  • Jak sprawdzić, czy testy naprawdę testują wszystko? Czy nie brakuje jakiejś asercji?
  • Jak przeanalizować raport z Testów Mutacyjncych.

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Czy testy testują wystarczająco dużo?

Skoro mierzony jest Code Coverage, czyli program przechodzi po wszystkich liniach kodu, a testy są zielone, można zadać pytanie: Czy naprawdę testujemy wszystkie przypadki brzegowe? Czy istnieje taka zmiana w kodzie, która nie zostałaby wykryta przez testy?

Jako przykład przytoczę kod, który określa koszt dostawy zamówienia w sklepie internetowym. Wymagania:

  • Do 50 km, zamówienie dostarczane jest za darmo.
  • Do 100 km obowiązuje stała cena 10 zł.
  • Powyżej 100 km obowiązuje stała cena + stawka 0,2 zł za każdy dodatkowy kilometr.
  • Użytkownicy premium zawsze mają dostawę za darmo.

Wyżej wymienione parametry cenowe i odległości zastąpię parametrami w kodzie, ale tych wartości użyję w testach.

Przykładowa implementacja wyżej wymienionych wymagań:

package pl.softwareskill.example.codecov;

import lombok.RequiredArgsConstructor;

import static pl.softwareskill.example.codecov.UserType.Premium;

@RequiredArgsConstructor
public class ShippingCostCalculator {

    private static final long METERS_IN_KILOMETER = 1000;

    private final long freeDeliveryMaxDistance;
    private final long fixedCostDistance;
    private final double fixedCost;
    private final double costPerKm;

    public double calculate(final long distanceInMeters, final UserType userType) {
        if (userType == Premium || distanceInMeters <= freeDeliveryMaxDistance) {
            return 0;
        }

        if (distanceInMeters <= fixedCostDistance) {
            return fixedCost;
        }

        return fixedCost + additionalCostPerKm(distanceInMeters);
    }

    private double additionalCostPerKm(long distanceInMeters) {
        long additionalDistanceInKm = (distanceInMeters - fixedCostDistance) / METERS_IN_KILOMETER;
        return additionalDistanceInKm * costPerKm;
    }
}

* Implementacja cen na typ double, nie jest najlepszym pomysłem (np. ograniczenia arytmetyki zmiennoprzecinkowej w postaci binarnej), ale dla uproszczenia przykładu, użyłem tego typu.

Dopiszę następujące przypadki testowe, które przychodzą mi do głowy na teraz, myśląc, że to główne przypadki:

package pl.softwareskill.example.codecov;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ShippingCostCalculatorTest {

    private final long FREE_MAX_DISTANCE = 50_000;
    private final long FIXED_COST_DISTANCE = 100_000;
    private final double COST_FIXED = 10;
    private final double COST_PER_KM = 0.2;

    private final long FAR_DISTANCE = FREE_MAX_DISTANCE + 1;

    ShippingCostCalculator shippingCostCalculator = new ShippingCostCalculator(FREE_MAX_DISTANCE, FIXED_COST_DISTANCE, COST_FIXED, COST_PER_KM);

    @Test
    void freeShippingForPremiumRegardlessDistance() {
        assertThat(shippingCostCalculator.calculate(FAR_DISTANCE, UserType.Premium))
                .isZero();
    }

    @Test
    void belowFixedCostDistance() {
        long belowFixedCostDistance = FIXED_COST_DISTANCE - 10_000;
        assertThat(shippingCostCalculator.calculate(belowFixedCostDistance, UserType.Regular))
                .isEqualTo(COST_FIXED);
    }

    @Test
    void pricePerKmIsAddedForBreachedFixedDistance() {
        long breachedDistanceKm = 10;
        long breachedDistance = breachedDistanceKm * 1_000;
        long distance = FIXED_COST_DISTANCE + breachedDistance;
        assertThat(shippingCostCalculator.calculate(distance, UserType.Regular))
                .isEqualTo(COST_FIXED + (COST_PER_KM * breachedDistanceKm));
    }
}

Po uruchomieniu testów JaCoCo (IntelliJ też) wskazuje na 100% line coverage. Oznacza to, że wszystkie warunki końcowe zostały osiągnięte:

Raport Code Coverage
  • Jak sprawdzić, czy testy naprawdę testują wszystko?
  • Czy nie brakuje jakiejś asercji?

Testy mutacyjne w Java

Testowanie mutacyjne polega na wprowadzeniu zmian w kodzie (mutacjach) oraz zaobserwowaniu, czy testy ją wykryją. Jeżeli nie – jest to potencjalny obszar do poprawy poprzez ulepszenie zestawu testów.

W Java dostępny jest framework Pitest przeprowadzający mutacje kodu. Projekt dostępny jest pod adresem: https://pitest.org/. Działa to, jako plugin do narzędzi budowania Maven/Gradle.

Pitest - framework do testów mutacyjnych i wyznaczania mutation coverage

Zasada działania

Zasada działania testów mutacyjnych – to sprawdzenie które linie kodu są uruchamiane przez które testy. Następne wprowadzenie mutacji i ponowne uruchomienie testów pokrywających daną linię. Tym sposobem podczas modyfikacji jednej linii nie są uruchamiane wszystkie testy.

Wynik działania jest oznaczony:

  • KILLED – oznacza, że testy wykryły wprowadzony błąd.
  • SURVIVED – oznacza, że testy „przetrwały”, czyli nie zauważyły wprowadzonej zmiany.
Przykładowy raport Pitest z działania testów mutacyjnych

Przykładowe mutacje

  • Zamiana warunków, np. <= zmieniony na < lub: = na !=
  • Podstawienie pod warunek stałej wartości zawsze true i zawsze false (sprawdzenie, czy warunek jest istotny)
  • Zmiana operacji arytmetycznych na przeciwne, np. + na – lub: * na /
  • Zwracanie z metod „pustej” wartości (0, null, pusty string)
  • Podstawienie stałej

Pełna lista mutacji przeprowadzanych przez Pitest: https://pitest.org/quickstart/mutators/

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Konfiguracja Pitest

Przykładowe repozytorium, z prezentowanym poniżej kodem oraz ze skonfigurowanym Pitest (mutation coverage), JaCoCo (code coverage), Github Contineous Integration oraz integracją z Codecov.io umieściłem w repozytorium:

Pitest działa jako plugin do narzędzi budowania Maven/Gradle. Przykładowa konfiguracja dla Maven:

<build>
    <plugins>
        <!-- mutation testing -->
        <plugin>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-maven</artifactId>
            <version>1.5.1</version>
            <executions>
                <execution>
                    <id>pit-report</id>
                    <phase>test</phase>
                    <goals>
                        <goal>mutationCoverage</goal>
                    </goals>
                </execution>
            </executions>
            <dependencies>
                <dependency>
                    <groupId>org.pitest</groupId>
                    <artifactId>pitest-junit5-plugin</artifactId>
                    <version>0.12</version>
                </dependency>
            </dependencies>
            <configuration>
                <targetClasses>
                    <param>pl.softwareskill.example.codecov.*</param>
                </targetClasses>
                <targetTests>
                    <param>pl.softwareskill.example.codecov.*</param>
                </targetTests>
                <mutators>
                    <mutator>STRONGER</mutator>
                </mutators>
            </configuration>
        </plugin>
    </plugins>
</build>

Wskazujemy dwa kluczowe parametry dla plugin`u:

  • targetClasses – pod jakim pakietem znajduje się testowany kod,
  • targetTests – pod jakim pakietem znajduje się kod testujący,
  • (opcjonalnie) mutators – lista mutacji/grup użytych w procesie. Pełna lista: https://pitest.org/quickstart/mutators/

Analiza raportu Mutation Coverage

Sprawdźmy zatem wynik działania testów mutacyjnych. Nie jest źle. Mutation Coverage 85%, 11/13, czyli 2 linie kodu nie są wystarczająco dobrze pokryte przez testy.

Raport z testów mutacyjnych - mutation coverage

Mogę przeanalizować konkretną implementację:

Raport z testów mutacyjnych - mutation coverage na poziomie klasy

Usprawnienie testów

W linii 18 zostały wprowadzone 4 zmiany, ale jedna z nich nie zgłosiła błędów w teście. Zmieniono <= na <. Sprawdzam zestaw testów i faktycznie, brakuje całego testu sprawdzającego darmową dostawę dla użytkownika, który nie jest premium, ale nie przekroczył odległości darmowej dostawy:

    @Test
    void freeShippingBelowFreeDeliveryDistance() {
        assertThat(shippingCostCalculator.calculate(FREE_MAX_DISTANCE, UserType.Regular))
                .isZero();
    }

Po uzupełnieniu – raport w tej części wygląda znacznie lepiej:

Raport z testów mutacyjnych - mutation coverage

Podobna mutacja dotyczy linii 22: Zmieniono <= na <. Faktycznie, posiadam test belowFixedCostDistance sprawdzający, czy za dostawę jest stała opłata, gdy jej dystans wynosi poniżej 100 km. Natomiast nie mam testu dla dokładnie 100 km. Dopisuję brakujący test:

    @Test
    void atFixedCostDistance() {
        long atFixedCostDistance = FIXED_COST_DISTANCE;
        assertThat(shippingCostCalculator.calculate(atFixedCostDistance, UserType.Regular))
                .isEqualTo(COST_FIXED);
    }

Ponownie uruchamiam testy mutacyjne, ale błąd w raporcie nadal występuje. Co się stało?

Gdyby zmienić warunek <= na <, kod przechodzi do dalszych instrukcji i uruchamia fragment obliczający koszt dostawy. Logika mówi o tym, że opłata wynosi: 10 zł (do 100 km) + dodatkowa opłata 0,20 zł za każdy km. Zatem dla dokładnie 100 km nie ma dodatkowych kilometrów i koszt finalnie będzie wynosił tyle samo, ile w warunku wyznaczonym linijkę zwracającą stały koszt dostawy, do której mutacja dotarła przez zmodyfikowanie warunku.

Wnioski

Testy mutacyjne wprowadzają zestaw modyfikacji i uruchamiają testy ponownie. Niestety metoda nie jest idealna z dwóch powodów:

  • Zestaw modyfikacji jest określony i nie pokrywa wszystkich potencjalnych błędów.
  • Wprowadzone mutacje mogą zgłaszać nadmiarowe przypadki, kiedy wprowadzona modyfikacja naprawdę nie ma wpływu na logikę.

Przeglądając raport, należy przeprowadzić ręczną analizę przypadków, w których testy nie wykryły zmiany. Jednak testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.

Jeśli przeczytałeś/aś ten artykuł i jeśli Ci się spodobał – będę bardzo wdzięczny, jeśli podzielisz się nim ze swoimi znajomymi i dołączysz do naszego fanpage na FaceBook – https://www.facebook.com/softwareskill

Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

.

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy 
„Droga do Seniora” 🔥💪

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Podsumowanie

Czym są testy mutacyjne

Testy mutacyjne to proces, w którym celowo wprowadzane są błędy w kodzie (mutacje), aby sprawdzić, czy testy nadal będą pozytywne. W takim przypadku oznacza to, że zestaw testów jest niewystarczający do pokrycia potencjalnie wprowadzonych błędów. Testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.

Czym jest Mutation Coverage

Mutation Coverage to metryka wskazująca na stosunek linii kodu poddanych modyfikacjom, które zostały wykryte przez testy w stosunku do wszystkich linii kodu. Wartość na poziomie 85% (11/13) oznacza, że w 2 liniach kodu zostały wprowadzone modyfikacje, które nie zostały wykryte przez istniejący zestaw testów.

Jak wprowadzić testy mutacyjne w projekcie Java

Użyj frameworka Pitest.

Obraz: Papier plik wektorowy utworzone przez macrovector – pl.freepik.com

Dyskusja