Inhaltsverzeichnis

minikurs(6): Testen

Erfahrung ist das, was du bekommst,
wenn du nicht das bekommen hast,
was du bekommen wolltest.
— N.N.

Testprotokoll

Voller Optimismus starten wir dieses Programm und notieren, wie es auf die Eingaben reagiert. Zuerst prüfen wir, ob das Programm für unsere Beispieldaten die richtigen Ergebnisse liefert.

Programmierer konzentrieren sich beim Entwerfen und Kodieren darauf, dass ein Programm läuft. Das Ziel des Testers besteht darin, die Schwachstellen des Programms zu finden. Der Tester versucht das Programm zu knacken, zu zerbrechen. Den entscheidenden Sprung über den eigenen Schatten tun wir, wenn wir nicht nur die naheliegenden "richtigen" Daten prüfen. Wir müssen wissen, wo die Grenzen des Programms liegen.

Fahrzeuge Reifen Autos Motorräder Bemerkung
4 10 1 3 ok
4 8 0 4 ok
1000000000 2000000000 ?? ?? Trotz korrekter Eingabe unsinnige Ergebnisse.
4 6 -1 5 Anzahl der Autos kann nicht negativ sein.
4 18 5 -1 Anzahl der Motorräder kann nicht negativ sein.
1 3 1 0 Einem Auto fehlt ein Rad.
1 1 0 1 Ein Einrad?
-1 -4 0 0 Auf unsinnige, negative Eingaben scheinbar sinnvolle Ergebnisse.
-1 -2 -2 -1 Auf unsinnige, negative Eingaben unsinnige negative Ergebnisse.
1 A ?? ?? Beide Ergebnisse sind völlig unsinnig durch eine fehlerhafte Eingabe.
A 2 ?? ?? Die zweite Angabe wird gar nicht mehr abgefragt.

Am Boden zerstört? Zu Beginn hatte ich vor der Einfachheit der Aufgabenstellung gewarnt. Es gibt mehr Möglichkeiten, Fehler zu machen als es "Richtige" gibt. Dabei war das ein klitzekleines Programm: 14 Quelltextzeilen mit 7 (?) Fehlern. Wir können uns trösten: Auch große Programme stecken auch voller Fehler. Allerdings schätzt man dort, dass auf etwa 500 Zeilen ein Fehler kommt. Warum dauert es so lange, gute Software zu bauen? Warum werden immer wieder Korrekturen (engl. updates, patches) herausgegeben?

Fehleranalyse

Das Programm funktioniert nicht. Damit ist der Test eigentlich abgeschlossen. Aus. Zurück zum Hersteller.

Um es besser zu machen, müssen wir verstehen, woher die Fehler kommen. Daraus lässt sich ableiten, wie den einzelnen Fehlerarten zu begegnen ist. Der Entwicklungszyklus des Programms beginnt von vorn…

Offensichtlich unsinnige Eingaben (negative Eingabewerte) können einfach erkannt und durch Steueranweisungen abgewiesen werden:

  if (fahrzeuge < 0 || reifen < 0) 
  { 
    ... // Wie reagieren? siehe unten
  }

Ein Eingabefehler (Buchstaben statt Zahlen) lässt sich ebenfalls erkennen:

  std::cin >> reifen;
 
  if (!std::cin)
  {
    ...
  } 

Die informelle Aufgabenstellung verleitete dazu, einige "selbstverständliche" Tatsachen nicht zu erwähnen. Die Auftraggeber wissen mehr über ihr Fachgebiet als sie dem Programmierer oder Softwareanalytiker verraten. Sie sind dann überrascht, dass die Software das nicht berücksichtigt. So kann die Zahl der Reifen nie ungerade sein. Trikes hatten wir ja ausdrücklich ausgeschlossen! Wieso ist das nicht implementiert:

  if (reifen % 2 != 0)
  {
    ...
  }

Zwei andere Fehler sind so peinlich, dass man im Erdboden versinken möchte. Dies wurde einfach übersehen:

  if (reifen > 4*fahrzeuge)
  {
    ...
  }
  if (reifen < 2*fahrzeuge)
  {
    ...
  }

Die gemeinsten Fehler sind jene, die auch bei korrekten Eingaben zu Rechenfehlern führen. Eine Grenze ist die größte positive Ganzzahl, die bei internen Rechnungen nicht überschritten werden darf:

  if (fahrzeuge > std::numeric_limits<int>::max() / 4)  // #include <limits>
  {
    ...
  }

In diesem Programm ist noch überschaubar, welche Werte entstehen und woher sie kommen…

Reaktion auf Fehler

Wie soll nun ein Programm reagieren, wenn ein Fehler bemerkt wurde? Für ein so kleines Programm genügt es, eine Fehlermeldung auszugeben und das Programm dann zu beenden. Wird allerdings an jeder Stelle in Programm, wo ein Fehler bemerkt wird, auch gleich der Reaktionscode festgeschrieben, kann man vor lauter Fehlerbehandlung den normalen Programmablauf bald nicht mehr erkennen.

Sinnvoller ist es, die Fehlerbehandlung zu lokalisieren. In C gab es nur die Möglichkeit, sich durchzuwursteln oder das Programm zu beenden.

parkplatz3.cpp
//: parkplatz3.cpp : Autos und Zweiraeder - R.Richter 2007-01-02
////////////////////////////////////////////////////////////////
 
#include <iostream>
#include <string>
#include <limits>
#include <cstdlib>
 
void fehler(std::string nachricht)
{
  std::cerr << "Fehler: " << nachricht << '\n';
  exit(1);
}
 
int main()
{
  int fahrzeuge, reifen, autos, mopeds;
 
  std::cout << "Fahrzeuge: ";
  std::cin >> fahrzeuge;
 
  std::cout << "Reifen: ";
  std::cin >> reifen;
 
  if (!std::cin)            fehler("fehlerhafte Eingabe");
  if (fahrzeuge > std::numeric_limits<int>::max() / 4) 
                            fehler("Anzahl der Fahrzeuge zu gross");
  if (fahrzeuge < 0)        fehler("Anzahl der Fahrzeuge negativ");
  if (reifen < 0)           fehler("Anzahl der Reifen negativ");
  if (reifen < 2*fahrzeuge) fehler("Anzahl der Reifen zu klein");
  if (reifen > 4*fahrzeuge) fehler("Anzahl der Reifen zu gross");
  if (reifen % 2 != 0)      fehler("Anzahl der Reifen ungerade");
 
  mopeds = (4*fahrzeuge - reifen)/2;
  autos = fahrzeuge - mopeds;
 
  std::cout << "Autos: " << autos << '\n'; 
  std::cout << "Motorraeder: " << mopeds << '\n'; 
 
  return 0;
}
                                                            //~

C++, Java und anderen Sprachen bieten die Möglichkeit, beim Erkennen eines Fehlers eine Ausnahme zu werfen. Diese Ausnahme kann vom umgebenden Programmblock wieder eingefangen werden.

parkplatz4.cpp
//: parkplatz4.cpp : Autos und Zweiraeder - R.Richter 2007-01-02
////////////////////////////////////////////////////////////////
 
#include <iostream>
#include <string>
#include <limits>
 
void fehler(std::string nachricht)
{
  throw nachricht;
}
 
int main()
{
  try
  {
    int fahrzeuge, reifen, autos, mopeds;
 
    std::cout << "Fahrzeuge: ";
    std::cin >> fahrzeuge;
 
    std::cout << "Reifen: ";
    std::cin >> reifen;
 
    if (!std::cin)            fehler("fehlerhafte Eingabe");
    if (fahrzeuge > std::numeric_limits<int>::max()) 
                              fehler("Anzahl der Fahrzeuge zu gross");
    if (fahrzeuge < 0)        fehler("Anzahl der Fahrzeuge negativ");
    if (reifen < 0)           fehler("Anzahl der Reifen negativ");
    if (reifen < 2*fahrzeuge) fehler("Anzahl der Reifen zu klein");
    if (reifen > 4*fahrzeuge) fehler("Anzahl der Reifen zu gross");
    if (reifen % 2 != 0)      fehler("Anzahl der Reifen ungerade");
 
    mopeds = (4*fahrzeuge - reifen)/2;
    autos = fahrzeuge - mopeds;
 
    std::cout << "Autos: " << autos << '\n'; 
    std::cout << "Motorraeder: " << mopeds << '\n'; 
  }
  catch(std::string nachricht)
  {
    std::cerr << "Fehler: " << nachricht << '\n';
    return 1;
  }
  return 0;
}
                                                            //~

Dieses Vorgehen hat mehrere Vorteile.

Sind alle Fehler beseitigt? Dann muss das Programm erneut übersetzt und getestet werden. Sind die Fehler beseitigt? Reagiert das Programm jetzt sinnvoll?

Es wäre schön, wenn die Tests automatisch ablaufen könnten. Das ist zumindest zum Teil möglich. Man schreibe ein oder mehrere Programme dafür… Tests gehören auch zur Software. Nur, wer stellt sicher, dass diese Testprogramme richtig funktionieren?

Bevor wir in eine Spirale ohne Ende geraten, wenden wir uns einem anderen Thema zu. Neben int main() {…} ist ein weiterer Programmblock void fehler(…) {…} aufgetaucht. Solche Programmteile heißen Unterprogramme.

Weiter: Teil 7.