C# Unit Testing Quick Tip: Verify that a call is awaited
Wer testgetrieben entwickelt und somit nur dann einen Produktivcode schreibt, wenn ein Test rot ist, kommt irgendwann an eine Stelle, an der er einen Test schreiben muss, um zu prüfen, ob ein asynchroner Aufruf auch awaited wird. Diese Tests sind zwar möglich, allerdings groß, schwer zu lesen und mühsam zu pflegen. Alternativ zu einem Unit-Test lässt sich das auch auf der Ebene des Compilers prüfen.
Da beide Wege sinnvoll sind, werde ich in diesem Artikel auch beide aufzeigen. Zuerst den Test und anschließend wie man sich ihn dank des Compilers sparen kann.
Szenario
Zur Veranschaulichung habe ich mir folgendes Szenario überlegt: Wir schreiben eine Anwendung, in der es einen geschützten Bereich gibt. Damit ein User Zugriff auf diesen Bereich bekommt, muss er sich registrieren. Wir speichern daraufhin die Daten des Users und verschicken eine Mail, um die Identität zu bestätigen. Hier gibt es drei Beteiligte: Den UserRegistrator, welcher diesen Prozess abbildet, das IUserRepository, welches uns das Speichern der User-Daten ermöglicht und den IMessageDispatcher, welcher die Nachricht zur Identitätsprüfung verschickt.
Wichtig in unserem Beispiel ist, dass die Benachrichtigung erst dann erfolgt, wenn die User-Daten gespeichert wurden. Die User-Daten an sich sind für das Beispiel zweitrangig und wurden zur besseren Übersicht weggelassen.
Der Test
Code sagt mehr als 1000 Worte, also:
Wenn wir das await vor _userRepository.CreateNewUser(); in Zeile 67 entfernen, wird der Test rot. Damit ist bewiesen, dass wir auch das Richtige testen.
Der „Trick“ ist, mithilfe der TaskCompletionSource die Kontrolle darüber zu übernehmen, wann CreateNewUser() fertig wird. Nach dem „Act“ können wir so in aller Ruhe prüfen, dass der nächste Aufruf, also _messageDispatcher.DispatchEmailVerificationMessage(); noch nicht erfolgt ist. Wer jetzt findet, das sei doch noch vertretbar viel Code, der bedenke, dass pro Aufruf der awaited werden soll, ein eigener Task sowie Asserts notwendig werden.
Bei genauem Hinsehen fällt auf, dass der Test zwar einen asynchronen Code aufruft, selbst allerdings weder await benutzt, noch als async markiert ist. Bei dieser Art von Test muss das so sein, da er sonst endlos laufen würde. Dafür müsste der beim „Act“ zurückgegebene Task erst nach den ersten Asserts completed werden. Da erst beim „Act“ awaited wird, kann dies allerdings nie passieren.
Die Lösung per Compiler
Das fehlende await im Test erzeugte die Compiler-Warnung „CS4014: Async method invocation without await expression“. Diese nutzen wir bei der Compiler-Lösung. Hierzu fügen wir in den Projekteinstellungen pro Projekt und pro Build-Konfiguration die Warnung CS4014 in die Liste der Warnungen ein, die als Fehler behandelt werden sollen. Dies führt dazu, dass sich das Projekt nicht kompilieren lässt. Da in TDD „not compiling“ mit einem fehlgeschlagenen Test gleichgesetzt ist, wären wir damit fertig.
Die Einstellung ist je nach IDE an etwas anderer Stelle und ich werde daher hier nicht beschreiben wo. Entscheidend ist, dass die Projektdateien den Knoten <WarningsAsErrors>CS4014</WarningsAsErrors> enthalten. Wenn weitere Warnungen als Fehler behandelt werden sollen, dann natürlich auch diese.
Sollte man wirklich einen asynchronen Aufruf nicht awaiten wollen, so kann man die Überprüfung mit ein wenig Pragma-Code ab- und wieder anschalten:
#pragma warning disable 4014
_userRepository.CreateNewUser();
#pragma warning restore 4014
Das sollte allerdings die Ausnahme bleiben und ist dann auch einen erklärenden Kommentar wert