Montag, 11. August 2014

Einfacher Testmodul für eine JavaScript-Funktion

Vor kurzem stellte ich mir die Aufgabe, die Implementierung der folgenden Funktion aus einer grösseren (von anderen verfassten) JavaScript-Datei zu vereinfachen. Dabei wollte ich beim Refaktorisieren das Verhalten der Funktion in verschiedenen Kontextzuständen mittels Tests festhalten.

Was sind - rein formal, nicht applikatorisch - die wesentlichen Charakteristika dieser Funktion?

function btn_vb_refresh() {

  var lSubpanel, lFcode = "", lDivId = "", lSubmit, lPages;

  if (!clientErrorCheck()) {
    return;
  }

  if (!check_page())
    return;

  lSubpanel = getText("subpanel");

  clearError();
  lSubmit = false;

  if (byId('input_vb_filter')) {
    setText('vb_cur_filter',getText('input_vb_filter'));
    }

  if (getText("panel") == 'dlpview') {

    if (!lSubpanel) {
      lSubpanel = 'sum';
    }

    lFcode = 'main__ajax_dlpview_refresh';
    lDivId = "vorbest_" + lSubpanel;
    lPages = 0;
    if (byId("vb_total_pages")) {
      lPages = parseInt(getText("vb_total_pages"), 10);
    }

    if (lPages === 0) {
      lSubmit = true;
    }

  } else {

    if (!lSubpanel) {
      lSubpanel = 'vku';
    }

    if (lSubpanel == 'vk') {
      lFcode = 'main__ajax_vk_refresh';
      lDivId = "vorbest_vk";
    }

    if (lSubpanel == 'vku') {
      lFcode = 'main__ajax_vku_refresh';
      lDivId = "vorbest_vku";
    }

    if (lSubpanel == 'dlp') {
      lFcode = 'main__ajax_dlp_refresh';
      lDivId = "vorbest_dlp";
    }

  }

  if (!lSubmit) {
    if (lDivId && byId(lDivId)) {
      lSubmit = false;
    } else {
      lSubmit = true;
    }
  }

  if (!lSubmit) {
    setCssForDatatableWidth();
    call_ajax(lFcode, '#' + lDivId);
  } else {
    genericSubmit('main__vb_refresh');
  }

  return;  // sic!
}
Wir können folgende formalen Eigenschaften dieser Funktion festhalten:
  • Die Funktion hat keine eigenen Aufrufparameter
  • Sie verwendet nicht das this-Objekt
  • Sie ruft andere Funktionen auf, die aber alle direkt global deklariert sind (keine Methoden eines Objekts)
  • Die aufgerufenen Funktionen haben entweder gar keinen, einen oder zwei stringförmige Parameter
  • Der Rückgabewert der aufgerufenen Funktionen wird entweder ignoriert oder auf Initialwert verglichen, oder wie ein String behandelt.
Diese Eigenschaften habe ich als Voraussetzungen an die zu testende Funktion angenommen, da ich in der konkreten Situation nicht mehr brauche. Später lassen sich bei Bedarf weitere Features dazubauen.

Wenn wir die Funktion nun auch inhaltlich genauer anschauen, so geht es offenbar um folgendes:

  • Die aufgerufenen Funktionen entstammen, bis auf die Funktion call_ajax, meiner minimalistischen JavaScript-Bibliothek minlib.js.
  • Die Funktion entscheidet anhand gewisser Kontextinformationen, ob ein Ajax-Request call_ajax oder ein genericSubmit ausgeführt werden soll.
  • Im Ajax-Fall ermittelt die Funktion die beiden Aufrufparameter lFcode und '#' + lDivId
Um diese Funktion isoliert durch Unit-Tests abzusichern, könnte man folgendes machen:
  • Die aufgerufenen Funktionen müssen mitsamt der beim Aufruf übergebenen Parameter protokolliert werden, um die richtige Abfolge der Aufrufe verifizieren zu können.
  • Die aufgerufenen Funktionen werden im globalen Kontext als Mocks definiert und können Werte zurückgeben, die den Verlauf der Funktion beeinflussen.
  • Die einzelnen Testfälle können dann verifizieren, dass die Funktion, abhängig von den jeweiligen Testdaten, entweder die Funktion call_ajax mit den erwarteten Parametern, oder die Funktion genericSubmit mit dem angegebenen fixen Parameter main_vb_refresh aufruft.
Ziel wäre, die Funktion für die Dauer des Umbaus in einer eigenen lokalen Datei zusammen mit Testdaten abzulegen und bei jeder Änderung durch Ausführung dieser Datei die Testsuite durchlaufen zu lassen, die das erwartete Verhalten sicherstellt.

Jeder einzelne Testfall stellt einen Ausführungspfad, d.h. eine bestimmte Kombination von Entscheidungen beim Durchlaufen des Codes dar. Nehmen wir folgenden Beispiel-Testfall:

  1. Die Funktion clientErrorCheck() liefert true zurück, was wohl bedeutet, dass die Daten keine Eingabefehler enthalten. Die Ausführung wird dann fortgesetzt.
  2. Auch die Funktion check_page() liefert true zurück, was entsprechend wohl bedeutet: die Daten "der angezeigten Seite" sind konsistent. Die Ausführung wird fortgesetzt.
  3. Das Feld subpanel möge den leeren String als Wert enthalten, getText('subpanel') liefert dann '' zurück, was als Wert der lokalen Variablen lSubpanel gespeichert wird.
  4. Das Feld panel enthalte den Wert 'dlpview', so dass die Ausführung in den if-Zweig der entsprechenden Abfrage läuft.
  5. Es existiere kein Feld mit der ID 'vb_total_pages'. Im Effekt wird die lokale Variable lPages auf 0 und somit lSubmit auf true gesetzt.
  6. Die Funktion entscheidet sich aufgrund dieser Vorgeschichte am Schluss, genericSubmit('main__vb_refresh') aufzurufen.
Dieser lange Prosatext liesse sich als Testfall in Form eines JavaScript-Hashs wie folgt definieren:
{ name:"dlpview: Submit if no pages",
  fixture:{
    clientErrorCheck:true,
    check_page:true,
    getText:{
      subpanel:'',
      panel:'dlpview'
    },
    byId:{
      vb_total_pages:null,
    }
  }, 
  expected_history:[ 
    { fname: 'genericSubmit', args: [ 'main__vb_refresh' ] }
  ],
  expected_rval:undefined 
  // hier bedeutungslos, da die Funktion keine Rückgabewerte hat 
}
Das zu schreibende Testmodul müsste als Interpreter für ein solches Testdatenformat fungieren:
  1. Für jeden Testfall, der in Form eines Objekts in obigem Format vorliegt, werden die Daten des Members fixture benutzt, um die Mockfunktionen geeignet vorzubelegen. Hierzu sind leider globale Variablen unvermeidlich, die Mockfunktionen müssen dem globalen Objekt global als Members hinzugefügt werden.
  2. Nun wird die Testfunktion aufgerufen. Bei jedem Funktionsaufruf innerhalb dieses Testaufrufs werden die Rückgabedaten gemäss fixture bedient, und der aktuelle Aufruf mitsamt seinen Argumenten fortgeschrieben.
  3. Nach dem Aufruf wird geprüft, ob die Geschichte sämtlicher Funktionsaufrufe, die die zu testende Funktion getätigt hat, den Array expected_history als echte geordnete Teilmenge enthält. expected_history muss also nicht die aktuelle Historie sämtlicher Funktionsaufrufe aufführen - das wäre zuviel Schreibarbeit, besser ist die Reduktion aufs Wesentliche. Vielmehr muss aus der aktuellen Historie der Funktionsaufrufe ableitbar sein, dass die Aufrufe der expected_history in der dort angegebenen Reihenfolge und mit den dort angegebenen Argumenten ausgeführt wurden.
  4. Es wird geprüft, ob die Funktion den erwarteten Rückgabewert expected_rval hat.
  5. Für jeden Testfall wird in einer Ergebniszeile mit dem Code ok oder not ok notiert, ob er bestanden wurde. Genauer gesagt, soll das Output dem einfachen Test Anything Protocol folgen.
Unter Verwendung einer Hilfsfunktion extend(), die alle Eigenschaften eines Hashs in einen zweiten Hash übernimmt:
  function extend(destination, source) {
    for (var property in source) {
      if (typeof source[property] === "object" && 
          source[property] !== null && destination[property]) { 
        extend(destination[property], source[property]);
      } else {
        destination[property] = source[property];
      }
    }
    return destination;
  }
lässt sich der Definitionsschritt für das Mocking wie folgt ausführen:
// Overall Setup: Define all functions in a global namespace  
  var fnames = {};
  test.cases.forEach(function(testCase){
      extend( fnames,Object.keys(testCase.fixture))
      });
  Object.keys(fnames).forEach( function(fname) {
      global[fname] = function() {
      return mock({ fname:fname, args:arguments }); 
      }      
    });                
Die Funktion mock() ist also eine allgemeine Umleitungsfunktion für alle Funktionsaufrufe, die in den Fixtures deklariert sind. Sie protokolliert den aktuellen Aufruf (Funktionsname und Argumente), und ermittelt dann den Rückgabewert aus der Fixture. Hierbei begnüge ich mich aktuell auf den Fall eines Aufrufs mit höchstens einem Argument (bei Bedarf lässt sich das auf beliebig viele Argumente erweitern):
  function mock(func) {
    var fval,rval;
    if ((fval = _mock[func.fname])) {
      if (func.args.length === 0) 
        rval = fval;
      else if (func.args.length == 1) 
        rval = fval[func.args[0]];
      }
    call_history.push(new FunctionCall(func.fname,func.args,rval));
    return rval;  
    }  
Nach diesen Vorbereitungen kann die eigentliche Testschleife laufen. Sie sollte nun keine Überraschungen mehr enthalten:
// Now process each individual test case                
  test.cases.forEach( function(testCase, testCaseIndex) {
    var msg = "";
    try {
      setup(testCase.fixture);
      rval = test.func();
      assert_call_history(testCase.expected_history);
      assert_equals(testCase.expected_rval,rval);
    } catch (e) { msg = e; }
    console.log( 
      result_line( testCase.name, testCaseIndex+1, msg) 
      ); 
    function result_line( name, index, msg) {
      var result = msg ? "NOT OK" : "OK"
      result += " " + index + " - " + name;
      if (msg) result += ": " + msg;
      return result;
      }
    });
Wer sich für das vollständige JavaScript interessiert, notiert als node.js-Modul, kann es sich auf meinem pastebin ansehen.

Die verschiedenen Ausführungspfade sind mit den folgenden dreizehn Testfällen abgedeckt, die ich während der Bearbeitung der Funktion immer mitlaufen lasse:

1..13
ok 1 - stop if client error check fails
ok 2 - stop if check_page fails
ok 3 - dlpview: Submit if no pages
ok 4 - dlpview: input_vb_filter will be transferred to vb_cur_filter if present
ok 5 - dlpview: No submit if there are pages
ok 6 - dlpview: Use 'sum' as default subpanel
ok 7 - Not dlpview, subpanel vk: Submit if the div doesn't exist
ok 8 - Not dlpview, subpanel vku: Submit if the div doesn't exist
ok 9 - Not dlpview, no subpanel: Use 'vku' as default
ok 10 - Not dlpview, subpanel dlp: Submit if the div doesn't exist
ok 11 - Not dlpview, subpanel vk: Ajax call if the div exists
ok 12 - Not dlpview, subpanel vku: Ajax call if the div exists
ok 13 - Not dlpview, subpanel dlp: Ajax call if the div exists
Am Ende hatte die Funktion die folgende Form.
function btn_vb_refresh() {

  if (!clientErrorCheck() || !check_page()) return;

  var panel = getText("panel");
  var subpanel = getText("subpanel") || getDefaultSubpanel(); 
  
  clearError();

// Inhalt des Eingabefilterfelds in ein benanntes Feld transportieren
  setText('vb_cur_filter',getText('input_vb_filter'));

// Server-Request aktualisiert entweder nur Tabelle oder ganze Seite
  if (actualizeTableOnly( )) {
    doActualizeTable( )
  } else {     
    genericSubmit('main__vb_refresh');
  }
  
  function actualizeTableOnly() {
// Nur wenn Tabelle blätterbar und der Tabellen-Container existiert    
    return (0 < parseInt(getText("vb_total_pages"), 10)) &&
           byId(tableContainerId());
    }
    
  function doActualizeTable() {
// Tabelleninhalt per Ajax-Call aktualisieren
    setCssForDatatableWidth();
    var view = (panel == 'dlpview' ? 'dlpview' : subpanel);                
    var fcode = 'main__ajax_'+view+'_refresh';
    call_ajax(fcode, '#' + tableContainerId());
    }
    
  function tableContainerId( ) {
    return "vorbest_" + subpanel;
    }    
    
  function getDefaultSubpanel() {
    return (panel == "dlpview" ? 'sum' : 'vku');
    }    

}
Die Verkleinerung der Zeilenzahl - neu sind es 48 statt vorher 77 Zeilen - stand nicht im Vordergrund. Eher ging es darum, die Frage "Was tut diese Funktion" klarer hervortreten zu lassen, und zwar durch den Code selbst, weniger durch Kommentare.

Um das Was mache ich vom Wie mache ich's besser zu trennen, sind kleine lokale (innere) Funktionen in JavaScript eine gute Idee: Im Hauptcode der Funktion steht der Name der inneren Funktion, der sagt, was gemacht wird. Die Implementierung, weiter hinten im Code, zeigt dann, wie es gemacht wird. So wird man beim Lesen des Hauptcodes nicht abgelenkt durch die vielen kleinen konkreten Implementierungsentscheidungen, mit denen die einzelnen Teilaufgaben gelöst wurden - nur bei Bedarf kann man in die Implementierung der lokalen Funktion hineinverzweigen.

Oft zeigt sich dann, dass eine Reihe dieser kleinen lokalen Funktionen verallgemeinerungswürdig sind, d.h. aus der Funktion herausgezogen werden können, da sie auch an anderen Stellen aufrufbar sind. Ein Kandidat hierfür ist in diesem Fall die Funktion getDefaultSubpanel()

Keine Kommentare :