Ile naprawdę
są warte
twoje testy?


Kuba Werłos


  • Kto mierzy pokrycie kodu w swoim projekcie?
  • Kto ma w CI próg pokrycia?
  • Kto pracował w projekcie ze 100% pokryciem?
  • Kto napisał test tylko po to, żeby zwiększyć pokrycie?
  • A kto napisał test bez asercji?
function shippingCost(Order $order): float
{
    $cost = 15.00;

    if ($order->weightKg > 30.0) {
        $cost += 9.00;
    }

    if ($order->isExpress) {
        $cost *= 2;
    }

    return $cost;
}
function testShippingCost(): void
{
    $order = new Order(weightKg: 40.0, isExpress: true);

    self::assertGreaterThan(0, shippingCost($order));
}
function shippingCost(Order $order): float
{
    $cost = 15.00;

    if ($order->weightKg >= 30.0) {
        $cost += 9.00;
    }

    if ($order->isExpress) {
        $cost *= 2;
    }

    return $cost;
}
function testShippingCost(): void
{
    $order = new Order(weightKg: 40.0, isExpress: true);

    self::assertGreaterThan(0, shippingCost($order));
}
function shippingCost(Order $order): float
{
    $cost = 15.00;

    if ($order->weightKg > 30.0) {
        $cost -= 9.00;
    }

    if ($order->isExpress) {
        $cost *= 2;
    }

    return $cost;
}
function testShippingCost(): void
{
    $order = new Order(weightKg: 40.0, isExpress: true);

    self::assertGreaterThan(0, shippingCost($order));
}
function shippingCost(Order $order): float
{
    $cost = 15.00;

    if ($order->weightKg > 30.0) {
        $cost += 9.00;
    }

    if ($order->isExpress) {
        $cost /= 2;
    }

    return $cost;
}
function testShippingCost(): void
{
    $order = new Order(weightKg: 40.0, isExpress: true);

    self::assertGreaterThan(0, shippingCost($order));
}

Co jest wart test,
który zawsze przechodzi?

function lateFee(float $amount, int $daysLate): float
{
    $fee = 0.0;

    if ($daysLate > 14) {
        $fee = $amount * 0.05;
    }

    if ($fee > 100.0) {
        $fee = 100.0;
    }

    return $fee;
}
function testLateFee(): void
{
    $fee = lateFee(5_000.0, daysLate: 30);

    self::assertSame(100.0, $fee);
}
function lateFee(float $amount, int $daysLate): float
{
    $fee = 1.0;

    if ($daysLate > 14) {
        $fee = $amount * 0.05;
    }

    if ($fee > 100.0) {
        $fee = 100.0;
    }

    return $fee;
}
function testLateFee(): void
{
    $fee = lateFee(5_000.0, daysLate: 30);

    self::assertSame(100.0, $fee);
}
function lateFee(float $amount, int $daysLate): float
{
    $fee = 0.0;

    if ($daysLate >= 14) {
        $fee = $amount * 0.05;
    }

    if ($fee > 100.0) {
        $fee = 100.0;
    }

    return $fee;
}
function testLateFee(): void
{
    $fee = lateFee(5_000.0, daysLate: 30);

    self::assertSame(100.0, $fee);
}
function lateFee(float $amount, int $daysLate): float
{
    $fee = 0.0;

    if ($daysLate > 14) {
        $fee = $amount / 0.05;
    }

    if ($fee > 100.0) {
        $fee = 100.0;
    }

    return $fee;
}
function testLateFee(): void
{
    $fee = lateFee(5_000.0, daysLate: 30);

    self::assertSame(100.0, $fee);
}

Chaos engineering

  • celowe wywoływanie awarii — najpewniejszy sposób,
    żeby sprawdzić, czy system je przeżyje
  • Chaos Monkey (Netflix) —
    losowo ubija serwery na produkcji
  • w godzinach pracy, nie w nocy

Fuzzing

  • zasypuje program masą losowych,
    uszkodzonych danych wejściowych
  • patrzy, czy coś się zepsuje —
    błąd krytyczny, zawieszenie, niezłapany wyjątek
  • OSS-Fuzz: dziesiątki tysięcy błędów
    znalezionych w open source
co
psuje?
czego
pilnuje?
chaos
engineering
infrastrukturęodporności
systemu
fuzzingdane
wejściowe
odporności
kodu
?kodjakości
testów
  • wprowadź jedną zmianę w kodzie
  • uruchom testy
  • świetnie, sabotaż wykryty
  • mamy problem
$daysLate > 14

Expr_BinaryOp_Greater(
    left: Expr_Variable(
        name: daysLate
    )
    right: Scalar_Int(
        value: 14
    )
)
$daysLate >= 14

Expr_BinaryOp_GreaterOrEqual(
    left: Expr_Variable(
        name: daysLate
    )
    right: Scalar_Int(
        value: 14
    )
)
  • mutacja — podmiana węzła AST
  • mutator — reguła podmiany
    np. $a + $b$a - $b
  • mutant — kod po podmianie
dla każdego węzła AST
    dla każdego mutatora, który go obsługuje
        dla każdej możliwej mutacji
            mutuj
            uruchom testy

Infection

0.35.4


composer require --dev infection/infection

vendor/bin/infection

  • pierwsze uruchomienie tworzy infection.json5
  • pokrycie kodu liczy sam — wystarczy PCOV lub Xdebug

Konfiguracja


{
    "staticAnalysisTool": "phpstan",
    "source": {
        "directories": ["src"]
    },
    "mutators": {
        "global-ignoreSourceCodeByRegex": [
            // don't touch: crucial for tool to work properly
            ".*\\\\PHP_VERSION_ID.*",
        ],
        "@default": true,
        // …
        "LogicalNot": {
            "ignore": [
                "PhpCsFixer\\Runner\\Runner::fixFile"
            ]
        }
    }
}

PHP-CS-Fixer/PHP-CS-Fixer — infection.json5.dist

ArrayAll ArrayAny ArrayFind ArrayFindKey ArrayFirst ArrayItem ArrayItemRemoval ArrayLast ArrayOneItem AssignCoalesce Assignment AssignmentEqual BCMath BitwiseAnd BitwiseNot BitwiseOr BitwiseXor Break_ CastArray CastBool CastFloat CastInt CastObject CastString CatchBlockRemoval Catch_ CloneRemoval Coalesce Concat ConcatOperandRemoval Continue_ Decrement DecrementInteger DivEqual Division DoWhile ElseIfNegation Equal EqualIdentical Exponentiation FalseValue Finally_ FloatNegation For_ Foreach_ FunctionCall FunctionCallRemoval GreaterThan GreaterThanNegotiation GreaterThanOrEqualTo GreaterThanOrEqualToNegotiation Identical IfNegation Increment IncrementInteger InstanceOf_ IntegerNegation LessThan LessThanNegotiation LessThanOrEqualTo LessThanOrEqualToNegotiation LogicalAnd LogicalAndAllSubExprNegation LogicalAndNegation LogicalAndSingleSubExprNegation LogicalLowerAnd LogicalLowerOr LogicalNot LogicalOr LogicalOrAllSubExprNegation LogicalOrNegation LogicalOrSingleSubExprNegation MBString MatchArmRemoval MethodCallRemoval Minus MinusEqual ModEqual Modulus MulEqual Multiplication NewObject NotEqual NotEqualNotIdentical NotIdentical NotIdenticalNotEqual NullSafeMethodCall NullSafePropertyCall OneZeroFloat Plus PlusEqual PowEqual PregMatchMatches PregMatchRemoveCaret PregMatchRemoveDollar PregMatchRemoveFlags PregQuote ProtectedVisibility PublicVisibility ReturnRemoval RoundingFamily SharedCaseRemoval ShiftLeft ShiftRight Spaceship SpreadAssignment SpreadOneItem SpreadRemoval SyntaxError Ternary This Throw_ TrueValue UnwrapArrayChangeKeyCase UnwrapArrayChunk UnwrapArrayColumn UnwrapArrayCombine UnwrapArrayDiff UnwrapArrayDiffAssoc UnwrapArrayDiffKey UnwrapArrayDiffUassoc UnwrapArrayDiffUkey UnwrapArrayFilter UnwrapArrayFlip UnwrapArrayIntersect UnwrapArrayIntersectAssoc UnwrapArrayIntersectKey UnwrapArrayIntersectUassoc UnwrapArrayIntersectUkey UnwrapArrayKeys UnwrapArrayMap UnwrapArrayMerge UnwrapArrayMergeRecursive UnwrapArrayPad UnwrapArrayReduce UnwrapArrayReplace UnwrapArrayReplaceRecursive UnwrapArrayReverse UnwrapArraySlice UnwrapArraySplice UnwrapArrayUdiff UnwrapArrayUdiffAssoc UnwrapArrayUdiffUassoc UnwrapArrayUintersect UnwrapArrayUintersectAssoc UnwrapArrayUintersectUassoc UnwrapArrayUnique UnwrapArrayValues UnwrapFinally UnwrapLcFirst UnwrapLtrim UnwrapRtrim UnwrapStrIreplace UnwrapStrRepeat UnwrapStrReplace UnwrapStrRev UnwrapStrShuffle UnwrapStrToLower UnwrapStrToUpper UnwrapSubstr UnwrapTrim UnwrapUcFirst UnwrapUcWords While_ YieldValue Yield_

Zmniejszenie znaczenia

  • array_filter($x)$x
  • [1, 2, 3][2, 3]
  • $em->flush();
  • return $foo;
  • Uciekł? Martwy kod albo brak testu.

Zamiana znaczenia

  • $a + $b$a - $b
  • truefalse
  • floor($x)ceil($x)
  • Uciekł? Brak przypadku testowego.

Zwiększenie znaczenia

  • $a > $b$a >= $b
  • $a !== $b$a != $b
  • Uciekł? Czasem winny mutant, nie test.
kategoriaile?
zmniejszenie znaczenia104
zamiana znaczenia59
zwiększenie znaczenia3

razem 166 mutatorów

@default 158

Extreme mutation testing

  • usuwa całe ciało metody zamiast jednego węzła
  • uciekł? metoda pokryta, ale nie przetestowana —
    testy przez nią tylko przechodzą
  • tak było z co dziesiątą pokrytą metodą
    w 19 projektach open source w Javie (Niedermayr, 2019)
  • w Infection tego nie ma —
    trzeba by zrobić własny mutator
vendor/bin/infection make:mutator MethodBodyRemoval

Mutant uciekł. Co dalej?

Zmiana nie została wykryta przez testy


  • nic nie sprawdza tego zachowania — dopisz asercję
  • kod jest zbędny — usuń go
  • nie warto go łapać
    $this->logger->info('Zapisano');
  • mutant równoważny — nie da się go złapać

Mutant równoważny


function capPercent(int $percent): int
{
    if ($percent > 100) {
        $percent = 100;
    }

    return $percent;
}
function capPercent(int $percent): int
{
    if ($percent >= 100) {
        $percent = 100;
    }

    return $percent;
}
stanco się stało?
złapanytest padł
uciekłtesty przeszły
niepokrytyżaden test nie dotknął tej linii
timeoutmutacja zapętliła program
niepoprawnykod się nie uruchamia albo się wywala

MSI

Mutation Score Indicator


                 złapane + timeout + niepoprawne
MSI = ──────────────────────────────────────────────────────
      złapane + timeout + niepoprawne + uciekły + niepokryte

Covered MSI

ten sam licznik,
mianownik bez niepokrytych

.: killed by tests, A: killed by SA, M: escaped, U: uncovered
E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

MMMMMMMM.MM.MMUUUUU..                                (21 / 21)
Escaped mutants:
================

1) src/Calculator.php:9    [M] GreaterThan

@@ @@
     {
         $cost = 15.00;
 
-        if ($order->weightKg > 30.0) {
+        if ($order->weightKg >= 30.0) {
             $cost += 9.00;
         }

21 mutations were generated:
       4 mutants were killed by Test Framework
       5 mutants were not covered by tests
      12 covered mutants were not detected

Metrics:
         Mutation Score Indicator (MSI): 19%
         Mutation Code Coverage: 76%
         Covered Code MSI: 25%
bibliotekapokrycieMSI
lcobucci/jwt100%98,1%
nesbot/carbon99,9%79,0%
beberlei/assert99,8%75,1%
webmozart/assert96,3%70,1%
symfony/yaml93,7%87,0%
vlucas/phpdotenv93,2%83,9%
guzzlehttp/guzzle90,3%91,0%
firebase/php-jwt86,0%76,0%
twig/twig85,2%78,4%
monolog/monolog70,9%52,0%

Problemy


  • wolno — każdy mutant to
    osobne uruchomienie PHPUnit
  • mutant potrafi zapętlić program: $i++$i--
  • w Google’u na początku 85% zgłoszonych mutantów
    nie było wartych poprawiania,
    po regułach wyciszania 11%
  • czy mutant jest równoważny,
    musi rozstrzygnąć człowiek
    15 minut na jednego (Schuler, Zeller, 2013)

W praktyce


  • wszystkie rdzenie
    --threads=max
  • tylko zmienione linie
    --git-diff-lines
  • próg w CI, jak dla pokrycia
    --min-msi=80
  • raport z uciekinierami
    --logger-html=infection.html

Mutanty wyższego rzędu

Dwie zmiany lub więcej naraz zamiast jednej


Jeden test: average(5, 3) ma dać 4

($a + $b) / 2   ⟶   4    oryginał
($a - $b) / 2   ⟶   1    złapany
($a + $b) * 2   ⟶  16    złapany
($a - $b) * 2   ⟶   4    uciekł

To był mutant pochłaniający —
dwa błędy zamaskowały się nawzajem

Jeden trudny mutant zamiast kilku łatwych

Uncle Bob Martin

x.com/unclebobmartin/status/2080257779395154409

Trójgłowy smok
kod
aplikacji
testytesty
mutacyjne

Dziękuję za uwagę


kubawerlos.github.io/slides