Untestbare Funktionalität deutet auf versteckte Software-Einheiten hin

22.06.2011

Eine Google-Suche nach "testing private methods" liefert über 70.000 Ergebnisse. Offenbar gibt es bei vielen Leuten das Bedürfnis, private Methoden zu testen. Ich finde das falsch.

Die Kurzfassung: Wenn die privaten Methoden nichts weiter als kleine Helfer sind, werden sie von den Tests der öffentlichen Methoden ohnehin erfasst. Sind sie jedoch komplex und von außen nicht zu testen, handelt es sich wohl eher um eine versteckte Schnittstelle zwischen dem äußeren, bekannten Objekt und einem implizit vorhandenen, noch nicht erkannten Objekt.

Es macht keinen Unterschied, ob sich die nicht testbare Funktionalität in einem Objekt oder einer Funktion befindet, deswegen ist das Beispiel in diesem Artikel eine JavaScript-Funktion. Genausogut könnte man diese Funktion aber auch an String.prototype hängen.

Stellvertretend für eine berechnungsintensive Funktion habe ich camelize() geschrieben – sie akzeptiert eine Zeichenkette wie „foo_bar_baz“ und macht eine Version in CamelCase daraus, „fooBarBaz“. Das ist recht leicht:

var camelize = (function(){
	var underscoreFollowedByLowercaseCharRE = /_([a-z])/g;

	function returnUpcasedCharAfterUnderscore(matchedSubstring, charAfterUnderscore){
		return charAfterUnderscore.toUpperCase();}

	return function(string){
		return string.replace(
			underscoreFollowedByLowercaseCharRE,
			returnUpcasedCharAfterUnderscore);};})();

Eine kleine TestSuite ist dafür recht leicht, in diesem Fall habe ich ohnehin testgetriebene Entwicklung angewandt:

describe('Camelize', function(){

	it('returns the empty string if given an empty string.', function(){
		expect(camelize('')).toEqual('');});

	it('returns an unmodified string if the string does not contain "_".', function(){
		expect(camelize('foo')).toEqual('foo');});

	it('returns "fooBar" if given "foo_bar".', function(){
		expect(camelize('foo_bar')).toEqual('fooBar');});

	it('returns "thisIsACamelizedString" if given "this_is_a_camelized_string".', function(){
		expect(camelize('this_is_a_camelized_string')).toEqual('thisIsACamelizedString');});
});

Wie ich bereits schrieb, ist camelize() nur der Platzhalter für eine sehr, sehr rechenintensive Funktion. Also möchte ich bereits berechnete Werte in einem Cache speichern. Eine solche Variante (direkt geschrieben, keine Tests) ist ebenfalls nicht allzu schwierig:

var camelize = (function(){
	var cache = {};

	var underscoreFollowedByLowercaseCharRE = /_([a-z])/g;

	function returnUpcasedCharAfterUnderscore(matchedSubstring, charAfterUnderscore){
		return charAfterUnderscore.toUpperCase();}

	function createCacheEntryIfNecessary(string){
		if (!cache.hasOwnProperty(string)){
			cache[string] = string.replace(
				underscoreFollowedByLowercaseCharRE,
				returnUpcasedCharAfterUnderscore);}}

	return function(string){
		createCacheEntryIfNecessary(string);
		return cache[string];};})();

Nun möchte ich natürlich wissen, ob der Cache auch funktioniert. Es könnte immerhin sein, dass mir ein Fehler unterlaufen ist und gar keine Werte dort abgelegt werden – dann wäre die speichernde Version genauso rechenintensiv, aber komplizierter. Also muss auch der Cache getestet werden.

Dummerweise geht das nicht. Der Cache ist von außen unerreichbar, denn die Funktion verhält sich mit genauso wie ohne. Es ist nicht möglich, Tests zu schreiben! Das ist schlecht. Nun könnte der Ruf nach dem Testen der privaten Funktionalität kommen. Dazu müssten entweder irgendwelche Tricks hinzugenommen werden, die aber nicht jede Sprache erlaubt (Reflection z.B., aber sowas gibt es in JavaScript nicht) oder der Cache müsste irgendeine Schnittstelle nach außen erhalten. Das will ich aber gar nicht!

Wo liegt der Fehler? Ganz einfach: camelize() ist eine Funktion, die zwei Aufgaben hat! Zum einen soll sie Zeichenketten umwandeln, zum anderen einen Cache implementieren. Eine klare Verletzung des Single Responsibility Principles. Also muss eine zweite Funktion her. Diese nenne ich sinnigerweise cache(). In diesem Beispiel ist diese Funktion simpel – sie nimmt als einzigen Parameter eine ein-argumentige Funktion an und gibt eine ein-argumentige Funktion zurück, die das Gleiche macht wie die ursprünglich übergebene Funktion, nur dass sie für alle Eingaben, die bereits einmal aufgetreten sind, das Ergebnis aus einem Zwischenspeicher holt.

Das lässt sich in eine simple Spezifikation gießen:

describe('Cache', function(){
	function id(string){
		return string;}

	var changeCalls;

	function change(string){
		changeCalls++;
		return 'prefix_'+string;}

	beforeEach(function(){
		changeCalls = 0;});

	it('returns results of the wrapped function.', function(){
		expect(cache(change)('foo')).toEqual('prefix_foo');
		expect(cache(change)('bar')).toEqual('prefix_bar');
		expect(cache(id)('baz')).toEqual('baz');});

	it('calls the wrapped function only once if the same argument is used several times.', function(){
		var c = cache(change);
		c('foo');
		c('foo');
		expect(changeCalls).toEqual(1);});
});

Auch die Funktion cache() selbst ist nicht gerade ein Ausbund der Komplexität:

function cache(func){
	var values = {};

	return function(string){
		if (!values.hasOwnProperty(string)){
			values[string] = func(string);}
		return values[string];};}

Eine zu camelize() äquivalente Funktion mit Zwischenspeicherung lässt sich nun ganz einfach per cache(camelize) erzeugen. Der Bedarf, versteckte Funktionalität zu testen, ist gänzlich verschwunden. Außerdem wurde eine wiederverwendbare Cache-Funktion geschaffen, die auch in anderen Fällen angewendet werden könnte (natürlich ist sie in diesem Fall recht simpel).

Kleine Bemerkung zum Schluss: Mit testgetriebener Entwicklung wäre das Problem der zweiten Variante (mit dem „versteckten“ Cache) nie aufgetreten – dort wird nur implementiert, was auch an Tests formuliert wurde. Insofern kann es nicht passieren, dass man plötzlich in die Verlegenheit kommt, private Methoden oder versteckte (Hilfs-)Funktionen testen zu müssen.

Der gesamte Code ist auf Github verfügbar.