Im Zusammenhang mit der Qualitätssicherung von Software hat sich der Begriff Mutation Testing etabliert. Mithilfe von Mutationstests werden Änderungen am Quellcode durchgeführt, um die Tests zu testen, die die Software testen. 🙂

Ein populäres Werkzeug für solche Tests ist Stryker. Sehen wir uns an, wie uns Stryker dazu bewegt, qualitativere Tests zu schreiben.

Beispielprogramm – Setup

Das Beispielprojekt findet ihr auf GitHub.

Für dieses Tutorial verwenden wir Visual Studio Code und npm. PowerShell oder CMD öffnen und folgende Befehle eingeben, um die benötigten Ordner zu erstellen. Unter src wird unser Softwarecode liegen (da wir für Mutationstests den Quellcode brauchen) und die entsprechenden Tests unter test:

mkdir BaseConverter
cd BaseConverter
mkdir src
mkdir test
code .

Nun ist Visual Studio Code offen und wir können mit der Initialisierung des Projekts beginnen. Dazu im Terminal aus VS Code npm init eintippen und die Anweisungen im Terminal befolgen. Optional können die Felder ausgelassen werden und später in package.json nachträglich geändert werden.

Dependencies installieren

Als Testrunner und Assertion Library werden Mocha und Chai, sowie ts-node für die TypeScript Tests installiert. Um die Type Definitions für Mocha und Chai zu installieren, benötigen wir @types/chai und @types/mocha Packages.

npm install mocha chai ts-node --save-dev
npm install @types/chai @types/mocha --save-dev

Da wir mit einem TypeScript Projekt arbeiten, sollten wir tsc --init ausführen, was uns standardmäßig eine tsconfig.json erstellt.

Tests implementieren

Wir wollen den Ansatz von Test-Driven-Development verfolgen und zuerst die Tests für das Projekt BaseConverter implementieren, welches 2 Funktionen besitzt: Konvertieren einer Dezimalzahl ins binäre Format und andersherum. Dazu eine Datei unter test anlegen und die entsprechenden Tests schreiben:

import { expect } from 'chai';
import 'mocha';

describe('Base Converter Tests', () => {

    it('Should convert positive decimal to binary.', () => {
        expect(convert_decimal_to_binary(8))
            .to.equal('01000', 'Converted number was not as expected!');
    });

    it('Should convert negative decimal to binary.', () => {
        expect(convert_decimal_to_binary(-8))
            .to.equal('11000', 'Converted number was not as expected!');
    });

    it('Should convert negative binary to decimal.', () => {
        expect(convert_binary_to_decimal('11000'))
            .to.equal(-8, 'Converted number was not as expected!');
    });

    it('Should convert positive binary to decimal.', () => {
        expect(convert_binary_to_decimal('01000'))
            .to.equal(8, 'Converted number was not as expected!');
    });

});

Bevor wir die Tests starten, müssen wir in package.json „scripts“: { „test“: .. } anpassen, damit mocha die TypeScript Tests findet und ausführen kann:

"mocha -r ts-node/register test/**/*.ts"

Die Tests können einfach mit dem Befehl npm test ausgeführt werden, diese werden jedoch nicht kompiliert, weil die Methoden convert_decimal_to_binary() und convert_binary_to_decimal() fehlen. Also unter src/BaseConverter.ts diese implementieren:

function convert_decimal_to_binary(n: number): string {
    if(n == 0 ) return '0';

    const sign: string = n < 0 ? '1' : '0';

    let absoluteNumber: number = Math.abs(n);
    let convertedDecimal = '';

    while(absoluteNumber > 0) {
        const reminder: number = absoluteNumber % 2;
        convertedDecimal += reminder.toString();
        absoluteNumber = Math.floor(absoluteNumber / 2);
    }
    convertedDecimal += sign;
    const separator = '';
    return convertedDecimal.split(separator).reverse().join(separator);
}

function convert_binary_to_decimal(binaryNumber: string): number {
    const sign: number = binaryNumber.at(0) === '1' ? -1 : 1;
    const absoluteBinary: string = binaryNumber.slice(1);
    const absoluteDecimal: number = absoluteBinary.split('').reverse()
        .reduceRight((_, curr, index) => _ + Math.pow(2, index) * parseInt(curr), 0);
    return sign * absoluteDecimal;
}

export {
    convert_decimal_to_binary,
    convert_binary_to_decimal
};

Wenn alles richtig implementiert wurde, sollten die Tests jetzt alle erfolgreich laufen.

Stryker einsetzen

Die Tests waren alle erfolgreich, wir haben also abgeprüft, ob die implementierten Funktionen sowohl positive als auch negative Zahlen von einem Zahlensystem ins andere konvertiert. Mit Stryker sollten diese Tests auf Vollständigkeit überprüft werden. Folgende Befehle eingeben und wieder die Anweisungen im Terminal befolgen:

npm install --save-dev @stryker-mutator/core
npx stryker init

Ein letztes Package muss für unser TypeScript Projekt installiert werden, Stryker TypeScript Checker:

npm install --save-dev @stryker-mutator/typescript-checker
Stryker konfigurieren

Die Konfigurationsdatei stryker.conf.json muss zuletzt noch angepasst werden, damit Stryker richtig konfiguriert wird. Diese beinhalten u.a. Optionen für unseren Test Runner (in unserem Fall Mocha), welche Dateien mutiert werden sollen und TypeScript-spezifische Konfigurationen:

"mochaOptions": {
    "spec": ["test/**/*.js"],
    "package": "./package.json"
  },
"checkers": ["typescript"],
"tsconfigFile": "tsconfig.json",
"buildCommand": "tsc -b",
"mutate": [ "src/**/*.ts"]

Nach der Ausführung von npx stryker run gibt es im Ordner reporter einen HTML-Report.

Wir haben einen Mutation Score von 90% erreicht. Wenn wir uns den geänderten Quellcode ansehen, können wir herausfinden, welche Mutanten nicht von unseren Tests getötet worden sind. Hier haben wir einen Testfall vergessen, und zwar einen direkten Test mit 0:

it('Should convert decimal zero to binary.', () => {
        expect(convert_decimal_to_binary(0))
            .to.equal('0', 'Converted number was not as expected!');
});

Nachträglich hinzugefügt erreichen wir trotzdem keinen Mutation Score von 100%.

Warum? Laut Stryker hat der Mutant aus Zeile 2 mit der geänderten Bedingung zu false überlebt. Das ist auch richtig so, denn bei false wird die Zeile bei einem Parameter von 0 übersprungen und in Zeile 4 abgeprüft, ob diese Zahl unter 0 liegt. Wenn nicht, wird die Konstante sign auf 0 gesetzt, welche im Quellcode in Zeile 14 zum Rückgabewert der Funktion eingefügt wird convertedDecimal += sign; Die Programme sind also äquivalent! Die Abfrage auf n === 0 kann im Quellcode weggelassen werden und erreichen somit vollständige Testüberdeckung!

Ist das aber trotzdem richtig? Nicht ganz. Hier kommen die Einschränkungen von Stryker ins Spiel. Die Methode convert_binary_to_decimal() wurde gar nicht mutiert, da Stryker nicht die entsprechenden Mutationsoperatoren für die Logik hinter dem Code besitzt.

Zusammenfassung

Durch das Werkzeug Stryker zur Unterstützung von Mutationstests haben wir die Möglichkeit, unsere Tests auf Vollständigkeit zu testen. Falls es Mutanten eines Programms gibt, die trotz unserer Tests überleben, sollten wir uns Gedanken über bessere Tests machen. Nichtsdestotrotz kommt es auf die tatsächliche Implementierung der Software an und wie stark ein Werkzeug ein Programm mutieren kann, andernfalls kann es zu false positives kommen, da wir, wie in der obigen Grafik zu sehen ist, einen Mutationsscore von 100% erreichen, obwohl wir nicht alles getestet haben (z.B. Testfall von 0 aus dem Binärsystem ins Dezimalsystem fehlt, oder falls ein String für convert_binary_to_decimal() leer ist…).

0 Kommentare

Hinterlasse einen Kommentar

An der Diskussion beteiligen?
Hinterlasse uns deinen Kommentar!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert