5.3 Testgetriebene Entwicklung mit KI
Bei der testgetriebenen Entwicklung beginnen Sie nicht direkt mit der Entwicklung, sondern machen sich zunächst Gedanken über die Anforderungen, die Ihre Codeeinheit, meist eine Funktion, erfüllen soll, und formulieren diese in Form eines oder mehrerer Tests. Anschließend implementieren Sie die Funktion. Dabei gehen Sie in kleinen Schritten, den sogenannten Babysteps, vor und implementieren die Funktion Schritt für Schritt oder, besser gesagt, Test für Test.
Die testgetriebene Entwicklung (Test-driven Development) ist ein zyklischer Prozess, der häufig als TDD-Zyklus beschrieben wird. Er besteht aus den drei Phasen »red«, »green« und »refactor«. Am Anfang steht ein fehlschlagender Test, da dieser vor der Implementierung steht. Die Farbe Rot steht für den Fehlschlag des Tests. Anschließend implementieren Sie die Programmlogik, die dafür sorgt, dass der Test erfolgreich ablaufen kann. Der Test wird dadurch grün. Im letzten Schritt haben Sie die Gelegenheit, Ihren Code zu optimieren. Sie führen ein Refactoring durch, bei dem die Schnittstelle nach außen gleich bleibt und sich an der Funktionalität nichts ändert. Wie Sie sich dabei von der KI helfen lassen können und welche Strategien es hierfür gibt, damit beschäftigt sich Kapitel 4, »Refactoring«.
Doch zurück zur testgetriebenen Entwicklung. In diesem Kapitel nutzen wir zwei verschiedene KI-Werkzeuge, um testgetrieben zu entwickeln. In der Realität werden Sie dies in den seltensten Fällen so machen. Dieses Beispiel soll Ihnen vielmehr zeigen, dass Sie sich in den verschiedenen Teilen des Entwicklungsprozesses helfen lassen können.
Als konkretes Beispiel für TDD setzen wir eine Funktion zur Überprüfung von Kreditkartennummern um. Eine solche Nummer setzt sich aus unterschiedlichen Teilen zusammen: Die ersten 6 Ziffern identifizieren das ausstellende Finanzinstitut, die folgenden Ziffern bilden die Kontonummer des Karteninhabers. Die letzten Ziffern, die für unser Beispiel relevant sind, bilden die Prüfziffer. Diese wird mit dem Luhn-Algorithmus berechnet.
Nachdem uns die KI Arbeit abnehmen und uns nicht unnötig beschäftigen soll, lassen wir hier zwei Werkzeuge zusammenarbeiten: ChatGPT schreibt den Test, und der GitHub Copilot sorgt für die Implementierung.
Den ersten Test implementieren lassen
Werkzeuge wie ChatGPT sind sehr hilfsbereit und versuchen meist, eine sehr umfassende Antwort zu geben. Bei unserer aktuellen Aufgabenstellung ist dies jedoch eher hinderlich. Um zu verhindern, dass das Werkzeug Ihnen zeigt, wie Sie Ihre Testumgebung aufsetzen, alle Tests und die Implementierung liefern, und Ihnen zeigt, wie die Tests ausgeführt werden, müssen Sie den Prompt sehr klar formulieren:
Prompt: Assume the environment is already set up and everything is installed. Create a test in vitest using TypeScript for a function called validateCreditCardNumber to validate a given credit card number. Use TDD and baby steps as a strategy. Only write the first test, no info how to run the test is needed.
Sure, let’s start with the first step in Test-Driven Development (TDD): writing a test for a non-existing function validateCreditCardNumber.
Step 1: Write the First Test. We will write a test that checks if the validateCreditCardNumber function exists and is callable. Let’s create a test file validateCreditCardNumber.test.ts:
import { describe, it, expect } from 'vitest';
import {
validateCreditCardNumber
} from './validateCreditCardNumber';
describe('validateCreditCardNumber', () => {
it('should be a function', () => {
expect(typeof validateCreditCardNumber).toBe('function');
});
});
Explanation of the code …
Mit diesem Prompt hält ChatGPT die Antwort knapp und fokussiert. Der generierte Code importiert die von Vitest benötigten Funktionen sowie die Implementierung der validateCreditCardNumber-Funktion. Der erste Test prüft lediglich, ob hinter dieser Variablen wirklich eine Funktion steckt. Speichern Sie den Code, wie die Antwort von ChatGPT vorschlägt, in einer Datei mit dem Namen validateCreditCardNumber.test.ts und führen Sie anschließend den Test mit npx vitest aus, erhalten Sie eine Fehlermeldung, die darauf hinweist, dass die Datei validateCreditCardNumber nicht existiert. Also legen Sie im nächsten Schritt die Datei validateCreditCardNumber.ts an.
Anschließend können Sie den GitHub Copilot anweisen, dass er die Implementierung für Sie generieren soll. Dies können Sie beispielsweise mit dem folgenden Prompt erreichen:
Prompt: Make the following test pass:
(Code des Tests)
Sure! Here’s the code block you can use to make the test pass:
export function validateCreditCardNumber(
cardNumber: string
): boolean {
// Your implementation here
return true;
}
Die Ausführung des Tests liefert mit dieser Implementierung ein grünes Ergebnis. Also ist der nächste Test an der Reihe.
Aktuell ist es nicht möglich, dass Sie mit GitHub Copilot direkt auf andere Dateien referenzieren. Versuchen Sie dies, erhalten Sie eine Fehlermeldung. Das soll sich jedoch in Zukunft mit dem Copilot Workspace ändern. Mit diesem Feature können Sie innerhalb eines Repositorys mit der AI arbeiten und so Programmieraufgaben erledigen, die in einen größeren Kontext eingebettet sind.
Der zweite Test
Wenn sich der erste Test noch im Kontextfenster der ChatGPT-Session befindet, können Sie auf dem vorherigen Prompt aufsetzen und ChatGPT bitten, den nächsten Test zu implementieren:
Prompt: Create the next test
Step 2: Write the Next Test. We will now write a test to check if the validateCreditCardNumber function correctly validates a valid credit card number. Here’s an updated version of validateCreditCardNumber.test.ts with the next test:
import { describe, it, expect } from 'vitest';
import {
validateCreditCardNumber
} from './validateCreditCardNumber';
describe('validateCreditCardNumber', () => {
(existierender Code)
it('should return true for a valid credit card number', () => {
// Example of a valid Visa card number
const validCreditCardNumber = '4111111111111111';
expect(validateCreditCardNumber(validCreditCardNumber))
.toBe(true);
});
});
Explanation of the Code …
Der zweite Test umfasst schon deutlich mehr Rahmenbedingungen: Er übergibt eine Kreditkartennummer in Form einer Zeichenkette und erwartet den Wert true als Rückgabewert. Führen Sie die Tests aus, stellen Sie fest, dass beide bereits ohne Veränderung des Quellcodes erfolgreich ausgeführt werden. Der Grund hierfür ist, dass die Funktion immer den Wert true zurückgibt. Also ist erst mal keine weitere Änderung am Code erforderlich, sondern gleich der nächste Test fällig.
Der dritte Test
Also ist direkt ChatGPT wieder an der Reihe mit dem nächsten Test. Durch den existierenden Kontext ist auch dieser Prompt sehr einfach gehalten:
Prompt: Create the next test
Sure, let’s proceed by writing the next test for the validateCreditCardNumber function. This test will check if the function correctly identifies an invalid credit card number. Here’s an updated version of validateCreditCardNumber.test.ts with the next test:
import { describe, it, expect } from 'vitest';
import {
validateCreditCardNumber
} from './validateCreditCardNumber';
describe('validateCreditCardNumber', () => {
(existierender Code)
it(
'should return false for an invalid credit card number',
() => {
// Example of an invalid credit card number
const invalidCreditCardNumber = '1234567890123456';
expect(validateCreditCardNumber(invalidCreditCardNumber))
.toBe(false);
});
});
Explanation of the code …
Dieser dritte Test ist ein klassischer Negativtest. Damit testen Sie, ob die Funktion auch mit einer falschen Eingabe korrekt umgeht. Das zwingt Sie gleichzeitig, die Implementierung der Funktion so anzupassen, dass der Algorithmus korrekt ist. Im Prompt übergeben Sie dem GitHub Copilot die drei Tests und weisen ihn an, den Code entsprechend zu verändern.
Prompt: Change the Implementation, so that the following 3 tests pass:
(der Code des Tests)
// Implementation of validateCreditCardNumber function
export function validateCreditCardNumber(
cardNumber: string
): boolean {
// Remove any non-digit characters from the card number
const cleanedCardNumber = cardNumber.replace(/\D/g, "");
// Check if the cleaned card number is empty or not
if (cleanedCardNumber.length === 0) {
return false;
}
// Use Luhn algorithm to validate the card number
let sum = 0;
let double = false;
for (let i = cleanedCardNumber.length - 1; i >= 0; i--) {
let digit = parseInt(cleanedCardNumber.charAt(i), 10);
if (double) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
double = !double;
}
return sum % 10 === 0;
}
Der Copilot hat die Aufgabe mit Bravour erfüllt und den Code so geschrieben, dass alle drei Tests erfolgreich durchlaufen können. Die einzelnen Schritte in der Funktion sind mit beschreibenden Kommentaren versehen, sodass Sie den Code gut nachvollziehen können.
Der Schritt von der leeren Funktion bis zur endgültigen Implementierung entspricht nicht der Idee von TDD. Hier wären noch weitere Zwischenschritte empfehlenswert gewesen. In diesem Fall hätten Sie bei der Gestaltung der Tests und auch beim Prompting für den Copiloten deutlich strikter sein und die KI besser lenken müssen. Auch hier gilt wieder: Je präziser Sie Ihre Prompts formulieren, desto besser können Sie das Ergebnis steuern.
Testdaten erzeugen
Sollten Sie noch skeptisch sein, ob die Implementierung wirklich funktioniert, können Sie noch einen weiteren Test generieren lassen, der eine ganze Reihe von Kreditkartennummern überprüft. Für einen solchen Massentest können Sie die it.each-Methode nutzen, um eine ganze Reihe von Werten zu testen und trotzdem Ihren Code kompakt zu halten. Der entsprechende Prompt könnte folgendermaßen lauten:
Prompt: Create 10 valid and 10 invalid credit card numbers and use it.each to verify if the validateCreditCardNumber function works correct.
Sure, let’s enhance our tests using it.each to validate multiple credit card numbers at once. We’ll create 10 valid and 10 invalid credit card numbers and use it.each to verify if the validateCreditCardNumber function works correctly.
Here’s the updated validateCreditCardNumber.test.ts:
import { describe, it, expect } from 'vitest';
import {
validateCreditCardNumber
} from './validateCreditCardNumber';
const validCreditCardNumbers =
[... validCreditCardNumbers ... ];
const invalidCreditCardNumbers =
[ ... invalidCreditCardNumbers ... ];
describe('validateCreditCardNumber', () => {
it('should be a function', () => {
expect(typeof validateCreditCardNumber).toBe('function');
});
it.each(validCreditCardNumbers)(
'should return true for a valid credit card number: %s',
(cardNumber) => {
expect(validateCreditCardNumber(cardNumber)).toBe(true);
});
it.each(invalidCreditCardNumbers)(
'should return false for an invalid credit card
number: %s',
(cardNumber) => {
expect(validateCreditCardNumber(cardNumber)).toBe(false);
});
});
Explanation of the code …
Durch den bestehenden Kontext hat ChatGPT diesen Prompt als Verbesserung, also Refactoring der bestehenden Tests, verstanden. Die Implementierung der validateCreditCardNumber bleibt unangetastet, der Code der Tests ändert sich in diesem Fall jedoch. Generell gilt: Auch ein Refactoring der Tests ist erlaubt. In diesem Beispiel definieren Sie zwei Arrays, eines mit gültigen Kreditkartennummern und eines mit ungültigen.
Der erste Test, ob validateCreditCardNumber eine Funktion ist, bleibt unverändert. Für die gültigen und ungültigen Kreditkartennummern nutzen Sie jeweils die it.each-Funktion, um für jeden Wert einen eigenen Test zu erzeugen. Die it.each-Funktion nutzt Platzhalter in der Beschreibung, damit in der Ausgabe der jeweilige Wert eingefügt wird.
Im nächsten Abschnitt erfahren Sie, wie Sie mit externen Abhängigkeiten in Ihren Tests umgehen können und wie Ihnen KI-Werkzeuge dabei helfen können.