JavaScript Code Refactoring automatisieren

Vor kurzem hatte ich die Muße ein älteres JavaScript Projekt zu refactoren. Unter anderem sollte die Assertion Bibliothek Jasmine von 1.x auf 2.x aktualisiert werden. Zwei Dinge gab es bei unseren Tests zu refactoren. Einmal die Art von asynchronen Specs und einmal die verwendeten Expectations. Unter http://jasmine.github.io/2.0/upgrading.html wurde super beschrieben was für Änderungen man genau machen muss beim Umstieg von Jasmine 1.x auf 2.x.
In diesem Artikel möchte ich zeigen, wie ich die Transformation der runs und waitsFor Blöcke zum neuen done Callback Muster mittels jscodeshift automatisiert habe.
jasmine_async_vergleich

jscodeshift / recast / esprima / codemods

Jscodeshift ist ein Werkzeug dass von Facebook gebaut wurde und recast erweitert. Dieses wiederum arbeitet mit dem Esprima Parser. Dieser baut einen abstrakten Syntaxbaum (engl. abstract source tree, AST) auf, der traversiert werden kann.
Mit jscodeshift ist es z. B. möglich, alle anonyme Funktionen herauszuholen und mit einem Namen ichBinNichtMehrAnonym zu ersetzen. Ein weiteres, nettes Feature wie ich finde, ist die Beibehaltung der originalen Code Formatierung (so weit möglich).
Man baut also ein Codeschnipsel welches anderen Code umschreibt. Dieses Codeschnipsel wird codemod genannt. Eine schnelle Google Suche bringt uns zu zwei interessanten Artikeln; die für das Folgende aber nicht unbedingt gelesen werden müssen 🙂

Automatisieren von Code Refactorings

Warum automatisieren mag sich der ein oder andere denken. Das kann mehrere Gründe haben. Zum einen hat man vielleicht keinen Auszubildenden zur Verfügung der die Tests umschreiben kann, zum anderen… Nein… Auszubildende bitte mit jscodeshift ersetzen. Also für die Arbeit des Refactorings… Aber zurück zu den Gründen der Automatisierung.
Spaß
Ich musste kurz überlegen wann ich ein Refactoring in JavaScript Projekten gemacht habe, bei dem ich auch Spaß hatte. Mir fiel keines ein. Die Arbeit war ist stupide: Suchen und Ersetzen. Für das automatisierte Code Refactoring wird ein Code Schnipsel geschrieben welches das Suchen und Ersetzen für mich übernimmt! Ich darf Code hacken!
Zuverlässigkeit
Ein generelles Argument zum Automatisieren trifft denke ich auch bei Code Refactoring zu. Es wird immer zu jeder Zeit an jeder Stelle das selbe gemacht. Es gibt keinen Finger der auf der Tastatur um eine Taste verrutscht. Es gibt keine Unachtsamkeit die zum Vergessen einer Stelle führt. Die Maschine erledigt zuverlässig was getan werden soll. Immer. Jederzeit. Überall.
Effektivität
Ist etwas automatisiert gilt es nur noch ein Knöpfchen zu drücken. Bezogen auf das Code Refactoring kann das Ergebnis in wenigen (Milli-)Sekunden bestaunt werden. Hier spreche ich aber noch nicht konkret von codemods und jscodeshift als Werkzeug. Eine Regex kann hier auch schon völlig ausreichen.
Eine Regex zum

  • löschen von Abschnitten wenn Bedingung A zutrifft
  • verschieben von Code Blöcken

kann entweder einmal geschrieben und nie wieder verstanden werden, oder ist gar unmöglich zu schreiben. Hier kommt jscodeshift mit codemods zur Rettung.

Codemods zum Upgrade von Jasmine 1.x auf 2.x

Eine codemod ist ein Codeschnipsel welches vorhandenen Quellcode transformiert. Im unserem Fall der Jasmine Migrierung von Version 1.x zu 2.x müssen transformiert werden:

  • spies
  • asynchrone Tests
  • expectations
  • custom matchers (falls vorhanden)
  • clock

Wir wenden uns folgend den asynchronen Tests zu.

Asynchrone Tests transformieren

Das Projekt das es zu refactored galt hatte überwiegend sehr einfach geschriebene asynchrone Tests. Perfekt für den Einstieg in jscodeshift.
Eine Variable die initial false ist und nach $Aktion auf true gesetzt wird. Die Assertions werden dann erst ausgeführt, wenn die Variable gesetzt wurde.

it('tests something async', function() {
  var done;
  doSomethingAsync(function callback() {
    // assertions
    done = true;
  });
  waitsFor(function() {
    return done;
  });
});

Für jasmine 2.x müssen wir also

  • einmal der Funktion die dem it übergeben wird einen Parameter done hinzufügen
  • done = true; mit done(); ersetzen
  • und den waitsFor Block löschen

Nach dem Refactoring soll das Ganze also wie folgt aussehen:

it('tests something async', function(done) {
  doSomethingAsync(function callback() {
    // assertions
    done();
  });
});

jscodeshift

Bevor wir loslegen können, müssen noch wenige Dinge erledigt werden.

$> npm install -g jscodeshift
$> mkdir jasmineCodemods && cd jasmineCodemods
$> git init && git commit -m "initial commit" --allow-empty
$> touch jasmine-async.js

Der Einfachkeit halber installieren wir jscodeshift global um das binary auf der Konsole ausführen zu können. Und das Git Repo zum einfachen hacken, sichern und zurückrollen darf auch nicht fehlen!
Dann legen wir uns eine Datei für der/die/das erste codemod an und schreiben folgenden Inhalt:

// jasmine-async.js
module.exports = function transformer(file, api) {
  const j = api.jscodeshift;
  const { statement } = j.template;
  const root = j(file.source);
  return root;
};

Auf root können wir jetzt jscodeshift Methoden aufrufen wie find, filter, forEach, replaceWith und zuletzt toSource. Die Methoden machen genau das was der Name sagt, selbsterklärend. Genaueres muss man sich leider selbst im Source Code auf Github zusammenkratzen.
Ausführen können wir das Skript später mit

$> jscodeshift -t ./jasmine-async.js pfad/zur/source/datei

Doch zuerst müssen Transformationen gecoded werden 😮
Wir wollen fürs erste alle it Knoten finden und der übergebenen Funktion einen done Parameter spendieren. Zum Suchen von Ausdrücken verwenden wir die jscodeshift Methode root.find. Diese traversiert den AST und gibt uns eine Collection von passenden Knoten zurück. Als Argument müssen wird dem find Aufruf eine AST Beschreibung des it Knotens mitgeben. Beim Finden der Beschreibung hilft uns der geniale astexplorer.net. Wir kopieren den Code den wir transformieren wollen in den Editor und bekommen den AST ausgespuckt. Wir können sogar auf jeden beliebigen Knoten im Editor klicken und bekommen im AST den enstsprechenden Teil markiert!
astexplorer
Wir müssen also nach allen CallExpression Knoten suchen, welche einen callee besitzen der das Attribut name mit dem Wert it besitzt.

// jasmine-async.js
return root
  .find(j.CallExpression, {
    callee: {
      name: "it"
    }
  })

Dann wollen wir für alle Knoten die gefunden werden etwas tun. Nämlich den done Parameter hinzufügen zur eigentlichen Testfunktion. Mit forEach können wir über die von find zurückgegebene Collection iterieren und dies tun.

// jasmine-async.js
return root
  .find(...)
  .forEach(p => {
    // p.node.arguments[0] would be the spec description
    const specCallee = p.node.arguments[1];
    // add 'done' parameter
    specCallee.params.push(statment`done`);
  })

Die Variable p ist der Pfad des gefundenen Knotens. Man könnte die Variable auch path benennen, würde sich aber beißen mit dem node Modul const path = require('path');. Das importieren dieses Moduls ist keine Seltenheit in codemods denke ich. Und als Konvention nehmen wir einfach p statt path, immer!
Der ASTExplorer zeigt wie wir an die Funktion kommen der wir den done Parameter hinzufügen möchten. Wir holen uns das zweite Element der CallExpression Argumente und fügen dessen Parameter Liste einfach das done hinzu. Leider (?) können wir aber keinen String übergeben. Wir erinnern uns an den AST. Wir brauchen eine Beschreibung des Knotens. Man könnte jetzt entweder ein komplexes Objekt erstellen, oder man nimmt sich einfach die nützliche statement Funktion zu Hilfe. Auf die Funktion machte mich ein Kollege aufmerksam. Sie ist leider nicht in der Doku zu finden sondern nur in codemods auf Github… Sagte ich schon, dass die Doku etwas spärlich ist?
Zum Abschluss müssen wir die Änderungen mit toSource() an jscodeshift zurückgeben um die Datei neu zu schreiben.

// jasmine-async.js
return root
  .find(...)
  .forEach(...)
  .toSource()

Zum schnellen Testen kann die Transformation auf der Konsole mit

$> jscodeshift -t ./jasmine-async.js pfad/zur/source/datei.js

ausgeführt werden. Nach dem ersten Staunen aber mit

$> git checkout HEAD -- pfad/zur/source/datei.js

zurück gesetzt werden.
Git \o/
Der erste Punkt ist erledigt.

  • einmal der Funktion die dem it übergeben wird einen Parameter done hinzufügen
  • done = true; mit done(); ersetzen
  • und den waitsFor Block löschen

Ersetzen wir als nächstes done = true; mit dem done() Aufruf. Dazu klicken wir im ASTExplorer auf den entsprechenden Ausdruck und schauen rechts im AST nach der Pfad Beschreibung die wir brauchen.

// jasmine-async.js
return root
  .find(...)
  .forEach(p => {
    // ...
    // replace 'done = true' with done() invocation
    j(p).find(j.ExpressionStatement, {
      expression: {
        type: j.AssignmentExpression.name,
        left: {
          name: 'done'
        }
      }
    }).replaceWith(p => statement`done();`);
  })
  .toSource()

Da wir wissen, dass done = true; nur einmal vorkommt, können wir der Collection direkt sagen bitte ersetzen mit dem Statement done();.
Die done Variable ist jetzt natürlich obsolet und kann komplett entfernt werden. Wieder schauen wir im ASTExplorer nach der Beschreibung die wir brauchen um auf folgendes zu kommen:

// jasmine-async.js
return root
  .find(...)
  .forEach(p => {
    // ...
    // get rid of 'var done = false'
    j(p).find(j.VariableDeclaration, {
      declarations: [
        {
          type: j.VariableDeclarator.name,
          id: {
            name: 'done'
          }
        }
      ]
    }).remove()
  })
  .toSource()

Zweiter Punkt auch erledigt.

  • einmal der Funktion die dem it übergeben wird einen Parameter done hinzufügen
  • done = true; mit done(); ersetzen
  • und den waitsFor Block löschen

Fehlt nur noch das Entfernen des waitsFor Blocks. Richtig geraten! Der ASTExplorer zeigt uns was wir suchen müssen.

// jasmine-async.js
return root
  .find(...)
  .forEach(p => {
    // ...
    // get rid of obsolete waitsFor block
    j(p).find(j.CallExpression, {
      callee: {
        name: 'waitsFor'
      }
    }).remove()
  })
  .toSource()

Fertig!
Schnell noch testen obs auch wirklich tut:

$> jscodeshift -t ./jasmine-async.js pfad/zur/source/datei.js

Und ab damit ins Repo

$> git commit -am "jasmine async test upgrade from 1.x to 2.x; automated 🎉"

Mit dieser codemod können wir ab sofort mit einem Befehl zig JavaScript Dateien transformieren lassen \o/ Wobei ich trotzdem ein Code Review empfehlen würde. Und nicht blind auf den Master pushen.

Ausblick

Zugegeben. Ich habe hier ein wirklich einfaches Beispiel gewählt. Aber trotzdem hat das viele Tests unseres Projektes abgedeckt. Ich habe noch ein paar Bedingungen hinzugefügt zur codemod wie z. B. bitte abbrechen, wenn Anzahl der waitsFor Blöcke != 1. Das ignoriert dann alles specs die synchron sind und asynchrone Tests die ein anderes Muster aufweisen. Bei uns waren das z. B. Integrations Tests die mehrere waitsFor Blöcke hatten weil mehrere Klicks simuliert wurden.
Es fehlen auch noch die restlichen jasmine Transformationen für Matchers und Spies. Aber ich denke es sollte recht klar geworden sein wie auch dafür codemods geschrieben werden können.
Werden codemods komplexer kann man über Unit Tests nachdenken. Hier gibt es Hilfe von jscodeshift. Als Beispiel hilft diese Stelle hier. Kurzer Ablauf: Als TestRunner wird jest benötigt. Der Quellcode der transformiert und der Quellcode der herausspringen soll, werden jeweils als Datei im Verzeichnis ‘__testfixtures__’ abgelegt. Der Test liegt im Verzeichnis ‘__tests__’ und definiert mittels den TestUtils einfach nur den Test. Codemods testgetrieben entwickeln steht nichts mehr im Weg.

// jasmine-async.spec.js
const defineTest = require('jscodeshift/dist/testUtils').defineTest;
defineTest(__dirname, 'jasmine-async');

Fazit

Trotz spärlicher Doku findet man sich nach gewisser Zeit zurecht. Vor allem der ASTExplorer ist eine große Hilfe dabei! Für der/die/das erste codemod habe ich vielleicht 3x länger gebraucht, als im Projekt alles per Hand zu ersetzen. Aber kennt man das Vorgehen mit jscodeshift gleicht sich das schnell wieder aus. Und der/die/das codemod kann wiederverwendet werden! Wenn auch mit kleinen Anpassungen für andere Gegebenheiten.
Oft verwendet habe ich bisher die js-codemods von @cpojer zum transformieren zu neuen es2015 Sprachfeatures.
Meine Empfehlung:
Einmal reinknien und machen! Vielleicht sogar für kleinere Projekte.

Kommentare

  1. AST explorer unterstützt jscodeshift auch im Live Modus; einfach unter 'Transform' den entsprechenden Eintrag wählen und direkt loslegen.
    https://astexplorer.net/#/XZgObKpwIY