Wie man Software durch Prüfung von Invarianten zuverlässiger macht

Software-Tests können nicht alle Fehler finden. Auch fehlerlos abgelaufene Tests, die alle Programmpfade durchlaufen haben, sind keine Garantie für fehlerfreie Software. Zusicherungen – in der Programmierersprache „Assertions“ genannt – schaffen Sicherheit. In einigen Fällen kann man Zusicherungen während des Compilier-Vorgangs implementieren. Sie brauchen dann weder Speicherplatz noch Rechenzeit.

Software-Tests können nicht alle Fehler finden. Auch fehlerlos abgelaufene Tests, die alle Programmpfade durchlaufen haben, sind keine Garantie für fehlerfreie Software. Zusicherungen – in der Programmierersprache „Assertions“ genannt – schaffen Sicherheit. In einigen Fällen kann man Zusicherungen während des Compilier-Vorgangs implementieren. Sie brauchen dann weder Speicherplatz noch Rechenzeit.

Unter der „Testbarkeit“ von Software verstehen manche Entwickler von eingebetteten Systemen ein Maß für die Kosten, die ein vollständiger Test der Software verursacht. In diesem Artikel ist unter der Testbarkeit etwas anderes zu verstehen. Testbarkeit versucht die Frage zu beantworten, wie wahrscheinlich es ist, dass ein Programm im Test einen erkennbaren Fehler zeigt, wenn es fehlerhaft ist. Wenn diese Wahrscheinlichkeit gering ist, dann ist die Testbarkeit gering: Es besteht die Gefahr, einen Fehler trotz hohen Testaufwands nicht zu erkennen. In der Literatur wird die Eigenschaft, fehlerhafte innere Zustände zu verbergen, als error masking bezeichnet [1].

Das beschriebene Phänomen des richtigen Ergebnisses trotz falscher Berechnung kann es zum Beispiel dann geben, wenn die Software bzw. die getestete Software-Einheit gedächtnisbehaftet ist: Das Gesamtergebnis ist korrekt, das für spätere Rechenschritte gespeicherte Zwischenergebnis jedoch falsch. Bewertet der Software-Test nur das Ergebnis und plausibilisiert das Zwischenergebnis aber nicht, so bleibt der Fehler unentdeckt, wenn keine weiteren Tests folgen, die das fehlerhafte Zwischenergebnis aufdecken.

Dem Programmierer steht ein sehr einfaches Werkzeug zur Verfügung, hier gegenzusteuern: die Verwendung von Zusicherungen. Im Falle der Programmiersprache C geschieht dies meist durch den Einsatz von assert(). Dieses Makro sollte häufig verwendet werden, wenn hohe Qualitätsansprüche an die Software bestehen. In manchen Fällen können Zusicherungen schon beim Übersetzen des Programms eingesetzt werden. Diese Compile-Time-Assertions verbrauchen weder Speicher noch Laufzeit.

Listing 1. Beispiel für eine Möglichkeit, Software „robust“ zu machen. Nicht definierte Kommandotypen führen zu einer wohldefinierten Reaktion.

void InterpretiereKommando1(unsigned *buffer, unsigned length)
{
	/* Der mögliche Kommandotyp steht in der ersten Adresse
	 * des Arrays buffer. Es gibt 3 Kommandos: (1) WarmUp,
	 * (2) CoolDown, (3) Restart. */
	unsigned kommando_typ = buffer[0];
	switch (kommando_typ)
	{
	case 1:
		Interpretiere_WarmUp(buffer + 1, length - 1);
		break;
	case 2:
		Interpretiere_CoolDown(buffer + 1, length - 1);
		break;
	default:
		Interpretiere_Restart(buffer + 1, length - 1);
		break;
	}
}

 

Listing 2. Mögliche Implementierung von assert.h. Wenn nicht im Debug-Modus übersetzt wird, wird die Zusicherung nicht übersetzt.

/* Datei assert.h, mitgeliefert mit dem Compiler */
#undef assert
#ifdef NDEBUG

/* Wenn Code ohne Debug-Info übersetzt wird, dann
 * wird das assert-Makro zu einem Null-Statement,
 * d.h. es wird nicht übersetzt. */

#define assert(exp) ((void)0)

#else

#ifdef __cplusplus
extern ”C“ {
#endif

void __cdecl _assert(void *, void *, unsigned);

#ifdef __cplusplus
}
#endif

/* Nur wenn mit Debug-Info übersetzt wird, dann wird
 * die Zusicherung auch tatsächlich ausgeführt */

#define assert(exp) (void)( (exp) || \ (_assert(#exp, __FILE__, __LINE__), 0) )
#endif /* NDEBUG */

 

Listing 3. Beispiel für eine Möglichkeit, das Programm aus Listing 1 „testbarer“ zu machen. Es wird eine Reihe von Erwartungshaltungen explizit in den Zusicherungen festgehalten.

#include
#include ”my_defs.h“

void InterpretiereKommando2(unsigned *buffer, unsigned length)
{
	unsigned kommando_typ;

	assert(length <= MAX_KOMMANDO_LAENGE);
	assert(length > 0);
	assert(buffer != NULL);

	kommando_typ = buffer[0];

	switch (kommando_typ)
	{
	case 1:
		Interpretiere_WarmUp(buffer + 1, length - 1);
		break;
	case 2:
		Interpretiere_CoolDown(buffer + 1, length - 1);
		break;
	default:
		assert(kommando_typ == 0);
		Interpretiere_Restart(buffer + 1, length - 1);
		break;
	}
}