15. Okt 2018
Lesedauer 16 Min.
Automation unter Test
Einstieg in die PowerShell, Teil 2
Die wichtigsten Aspekte einer testgetriebenen Entwicklung von PowerShell-Cmdlets.

Die professionelle Entwicklung von PowerShell-Befehlen kann ein aufwendiges Verfahren sein. Umso wichtiger ist eine hochwertige Arbeitsumgebung. Dieser Artikel zeigt die wichtigsten Aspekte für eine testgetriebene Entwicklung.Wer mit der Entwicklung von PowerShell-Cmdlets beginnt, steht vor einer großen Herausforderung. Obwohl die Entwicklung in Visual Studio erfolgen kann, steht für die Ausführung und damit für das Testen zunächst nur die PowerShell selbst zur Verfügung, also ein externer Editor, in dem das Skript geschrieben und ausgeführt wird.Wird das Cmdlet in einer solchen Umgebung instanziert, lässt es sich nicht debuggen. Die Fehleranalyse basiert dann vor allem auf den Ausgaben, die das Cmdlet auf der PowerShell-Konsole ausführt. Eine schrittweise Statusüberprü-fung, die automatisierte Überwachung von Zuständen und die detaillierte Inspektion von Variablen und deren Werten erscheinen nahezu unmöglich. Obwohl es mit Pester [1] ein Framework zum Testen von PowerShell-Skripten gibt, ist damit das Problem auf der Seite der Entwicklung von Cmdlets nicht gelöst. Betrachtet man diese Situation allein unter diesem Blickwinkel, bekommt man schnell den Eindruck, dass eine professionelle Entwicklung von PowerShell-Cmdlets, die hohen Ansprüchen genügt und die demzufolge auch noch Spaß macht, kaum realisierbar ist.Wünschenswert wäre hingegen die Möglichkeit, PowerShell-Cmdlets mit allem Komfort entwickeln zu können, der auch für die Entwicklung klassischer Komponenten zur Verfügung steht. Idealerweise würde man das auszuführende Skript testgetrieben entwickeln, dann eine spezifische Erwartungshaltung an das Ergebnis oder andere Rahmenbedingungen formulieren und könnte dann das Cmdlet in diesem Kontext Schritt für Schritt den funktionalen Erfordernissen anpassen.Die Perspektive für diese Vorstellung besteht in der Entwicklung eines PowerShell-Hosts, der die Verbindung zwischen Visual Studio und den auszuführenden Skripten herstellt. Das PowerShell-SDK in Form des NuGet-Pakets Microsoft.PowerShell.5.ReferenceAssemblies stellt eine solche Möglichkeit zur Verfügung, sodass die Entwicklung mit dem Erstellen einer Klassenbibliothek, einem Verweis auf das NuGet-Paket sowie auf das favorisierte Testframework beginnen kann.Die einfachste Form der Ausführung eines PowerShell-Skripts finden Sie in Listing 1. Das Skript besteht aus zwei Befehlen. Zunächst wird die Assembly, aus der der nächste Befehl kommt, mit Import-Module und einem Dateinamen als Parameter bekannt gemacht. Dieser wurde im vorangegangenen Artikel [2] bereits vorgestellt und ist ebenso wie alle hier aufgeführten Beispiele in seiner vollständigen Form unter [3] zu finden. Mit Add-Contact wird ein Kontaktdatensatz zu einer Beispieldatenbank hinzugefügt. Für die Ausführung wird zunächst eine PowerShell-Umgebung mit der statischen Methode Create erstellt und zu dieser das beschriebene Skript mit AddScript hinzugefügt. Ein Aufruf der Methode Invoke führt das Skript dann aus. Dass das auch tatsächlich der Fall ist, lässt sich mit einem Blick in die Datenbank erkennen. Ein direkteres Vorgehen ist, im Cmdlet bei der Methode ProcessRecord einen Haltepunkt zu setzen. Wird das Skript ausgeführt, hält die Ausführung an der markierten Stelle an.
Listing 1: Einfachste Form, ein Skript auszuführen
<span class="hljs-title">var</span> assemblyfile = new <span class="hljs-type">Uri</span>(typeof( <br/> <span class="hljs-type">AddContactCmdlet</span>).<span class="hljs-type">Assembly</span>.<span class="hljs-type">CodeBase</span>).<span class="hljs-type">LocalPath</span>; <br/><br/>var script = $@" <br/> <span class="hljs-type">Import</span>-<span class="hljs-type">Module</span> {assemblyfile} <br/> <span class="hljs-type">Add</span>-<span class="hljs-type">Contact</span> -<span class="hljs-type">FirstName</span> '<span class="hljs-type">Martina'</span> <br/> -<span class="hljs-type">LastName</span> '<span class="hljs-type">Mustermann'</span> <br/> "; <br/><br/>using (<span class="hljs-type">PowerShell</span> powerShell = <span class="hljs-type">PowerShell</span>.<span class="hljs-type">Create</span>()) <br/>{ <br/> powerShell.<span class="hljs-type">AddScript(script)</span>; <br/> powerShell.<span class="hljs-type">Invoke</span>(); <br/>}
Wie Listing 1 zeigt, ist der zentrale Einstiegspunkt zur Ausführung von PowerShell-Skripten der Typ PowerShell. Dieser wird über dessen statische Methode Create erstellt, von der die Variante ohne Parameter in den meisten Fällen ausreichen sollte. Die anderen Varianten sind für ein gewisses Feintuning zuständig, wenn man beispielsweise die zur Verfügung stehenden Standardbefehle einschränken möchte. Die Instanz des Typs PowerShell stellt dann verschiedene Formen zur Verfügung, Skripte bekannt zu machen. Die einfachste davon ist die Methode AddScript, der das Skript in Form eines Textes übergeben wird. Mit AddCommand können einzelne Befehle und mit AddParameter deren Parameter hinzugefügt werden. Es ist möglich, die Form des ausführbaren Programmcodes zu mischen und das Skript sowohl mit AddScript als auch mit AddCommand hinzuzufügen. Der Befehl Invoke führt den Code dann in der Reihenfolge aus, in der er hinzugefügt worden ist. Rückgabewert ist dann die Sammlung aller Ausgaben, die sämtliche Befehle während der Ausführung an die PowerShell-Ausführungsumgebung übergeben haben. Für die technischen Ausgaben, wie beispielsweise Fehlermeldungen, Warnungen oder einfache Informationen über die Ausführung, steht eine Eigenschaft Streams zur Verfügung, die wiederum über sechs Sammlungen Zugriff auf die einzelnen Typen dieser Ausgaben erlaubt. Die PowerShell verwaltet diese Informationen als Datensätze, was sich unter anderem in den Bezeichnungen ErrorRecord, DebugRecord und so weiter widerspiegelt.Ein weiteres wichtiges Element beim Ausführen von Skripten ist die Ausführungsumgebung Runspace. Diese wird über die gleichnamige Eigenschaft des PowerShell-Objekts zugeordnet. Erstellt man sie nicht selbst, wird die PowerShell mit der Ausführung des Skripts eine Ausführungsumgebung mit den Standardeigenschaften erstellen. Es lohnt sich jedoch, hier etwas mehr zu tun, denn über die Ausführungsumgebung werden zentrale Eigenschaften der Kommunikation mit der Umgebung gesteuert.Ein weiterer wichtiger Aspekt für die Entwicklung und demzufolge auch für das Testen ist die Allgegenwärtigkeit des Typs PSObject. Sämtliche Daten, die während der Ausführung eines Skripts zwischen den einzelnen Bestandteilen transportiert werden, sind in Instanzen dieses Typs verpackt. Es handelt sich dabei um ein dynamisches Objekt, dessen Eigenschaften erst zur Laufzeit hinzugefügt werden. Das ist gut für eine Skriptsprache, da somit flexibel auf die Ausführung reagiert werden kann. Aber es ist schlecht für stark typisierte Sprachen wie C# oder VB.NET, da man dynamische Objekte nicht so ohne Weiteres reflektieren kann. Allerdings bieten Objekte vom Typ PSObjekt auch Möglichkeiten an, über die Verpackung hinaus zum eigentlichen Wert des Objekts zu schauen, sodass sich auch hier die gesuchten Informationen ermitteln lassen.
Grundgerüst
Testgetriebene Entwicklung bedeutet ganz allgemein betrachtet das initiale Formulieren einer Testsituation, die Ausführung einer spezifischen Funktionalität und das anschließende Überprüfen der Ergebnisse, die bei der Ausführung entstehen. Für die testgetriebene Entwicklung eines PowerShell-Cmdlets mit einer .NET-Programmiersprache ist damit in gewisser Weise ein Medienbruch verbunden. Auf der einen Seite steht das PowerShell-Skript mit seinen Funktionalitäten, und auf der anderen Seite das .NET Framework, auf dessen Basis die Ausführung stattfindet und in dem die Rahmenbedingungen für die Ausführung des Skripts gesetzt werden und anschließend das Ergebnis überprüft wird. Damit die dabei anfallenden Aufgaben für die Überführung von Informationen in beide Richtungen einen Testfall nicht überladen, wurde die Klasse PowerShellTestBase erstellt, die alle Funktionalitäten für die Verbindung zwischen Ausführung eines Skripts und der anschließenden Überprüfung zur Verfügung stellt. Darin verwendet wird die Klasse ScriptExecuter, die das eigentliche Skript ausführt. Die Trennung zwischen Vorbereitung des Tests in der Basisklasse PowerShellTestBase und der eigentlichen Ausführung in ScriptExecuter ist nötig, um mehr Flexibilität in Bezug auf die Ausführung zu erlangen. So ist es damit möglich, die Skripte in einer Sandbox und damit in einem geschützten Bereich mit einer spezifischen Charakteristik auszuführen. Diese Klassen und der weitere Quellcode zu diesem Artikel sind Bestandteil eines PowerShell-Testframeworks, das unter [3] zu finden ist.Listing 2 zeigt einen Testfall, der auf der Basisklasse PowerShellTestBase beruht. Nach Formulieren des Skripts wird es mit der Methode RunScript ausgeführt und anschließend der Rückgabewert analysiert. Dieser ist eine Instanz vom Typ ExecutionResult, die verschiedene Informationen über die Ausführung enthält, die so aufbereitet sind, dass sie sich automatisiert überprüfen lassen. Der erste Punkt dabei ist der Rückgabewert eines Cmdlets. Der Begriff Rückgabewert ist allerdings nicht ganz korrekt, denn für die Methode ProcessRecord, die den Kern der Funktionalität eines Cmdlets darstellt, ist kein Rückgabewert festgelegt.Listing 2: Testfall mit Prüfung des Rückgabewerts
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">AddContactTests</span> : <span class="hljs-title">PowerShellTestBase</span> <br/>{ <br/> [Fact] <br/> <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">AddContactWithExplicitParameter</span>(<span class="hljs-params"></span>) </span><br/><span class="hljs-function"> </span>{ <br/> <span class="hljs-keyword">var</span> script = <span class="hljs-string">$@" </span><br/><span class="hljs-string"> Add-Contact -FirstName 'Martina' </span><br/><span class="hljs-string"> -LastName 'Mustermann' </span><br/><span class="hljs-string"> "</span>; <br/> <span class="hljs-keyword">var</span> result = RunScript(script); <br/> <span class="hljs-keyword">var</span> contact = result.Outputs.OfType <br/> &lt;Contact&gt;().FirstOrDefault(); <br/><br/> Assert.NotNull(contact); <br/> Assert.True(contact.Id &gt; <span class="hljs-number">0</span>); <br/> Assert.Equal(<span class="hljs-string">"Martina"</span>, contact.FirstName); <br/> Assert.Equal(<span class="hljs-string">"Mustermann"</span>, contact.LastName); <br/> } <br/>}
Stattdessen schreibt jedes Cmdlet das Ergebnis mit der Methode WriteOutput in die Ausführungsumgebung. Innerhalb des Skripts kann mit dem Cmdlet Write-Output (mit Bindestrich) auch eine Ausgabe erfolgen. Nach der Ausführung des Skripts lassen sich die gesamten Ausgaben einsammeln, sodass sie überprüft werden können. Das Cmdlet Add-Contact [2] übermittelt den gespeicherten Datensatz mit der Methode WriteOutput. Zur anschließenden Überprüfung gehört an dieser Stelle, ob es sich überhaupt um ein gültiges Objekt handelt, ob der Datensatz eine gültige ID hat und ob die Werte der Eigenschaften den erwarteten Werten entsprechen.Eine Ausnahme in Bezug auf das Abgreifen von Ausgaben einzelner Cmdlets stellen die Ausgaben dar, die innerhalb einer Pipeline weitergereicht werden. Wird die Ausgabe eines Cmdlets zu einer Eingabe des nachfolgenden, verschwindet es damit praktisch aus der Liste der festgehaltenen Ausgaben. Es gibt keine Möglichkeit, an diese direkt transportierten Werte heranzukommen. Bei der Ausführung einer Pipeline bleibt nur die Ausgabe des letzten Cmdlets erhalten. Sucht man einen Fehler in einer Pipeline, so muss man den klassischen Weg gehen und die Ausgaben eines Cmdlets in eine Variable schreiben und diese dann als Parameter dem nachfolgenden Cmdlet übergeben.Die Ausgaben eines Cmdlets während der Ausführung sind das wesentliche Element der Überprüfung auf Korrektheit einer Funktionalität. Dennoch ist es manchmal sinnvoll und hilfreich, einen tieferen und darüber hinausgehenden Blick in die Ausführung nehmen zu können. Die PowerShell erlaubt ein recht detailliertes Abfangen von Informationen während oder nach der Ausführung. Voraussetzung dafür ist allerdings, dass das Cmdlet entsprechende Informationen überträgt. Die Basisklasse PSCmdlet bietet insgesamt fünf Varianten an, Informationen während der Ausführung zu übertragen: Ausnahmen und Fehler, Warnungen, Debug-Informationen, allgemeine Informationen und Hinweise. Diese unterscheiden sich einerseits darin, was übertragen wird, und andererseits darin, wer diese Ausgaben zu sehen bekommt.Am wichtigsten für die testgetriebene Entwicklung von Cmdlets sind neben den normalen Ausgaben die Fehler, die während der Ausführung ausgelöst werden. Wie in den meisten Fällen ist es auch für ein Cmdlet sinnvoll, die Funktionalität in einem Try-Catch-Block zu verpacken und auftretende Fehler gezielt zu verarbeiten. Ein nicht behandelter Fehler landet direkt in der PowerShell-Befehlszeile, kann zwar auch dort innerhalb eines Try-Catch-Blocks verarbeitet werden, hat für das Cmdlet jedoch immer einen undefinierten Zustand zur Folge, wenn externe Ressourcen wie Datenbankverbindungen beteiligt sind.Aus der Sicht des Cmdlets werden Fehler mit der Methode WriteError an die Ausführungsumgebung weitergereicht. Einziger Parameter ist hier eine Instanz des Typs ErrorRecord, der wiederum mit einer Ausnahme und zusätzlichen Informationen wie der Fehlerkategorie erstellt wird. Diese Informationen ergänzt die PowerShell dann um weitere Daten und reicht sie an die Ausführungsumgebung weiter, wo sie ohne Probleme abgefangen und ausgewertet werden können. Zu den Informationen, welche die PowerShell beim Weiterreichen einer Ausnahme ergänzt, gehört auch die Eigenschaft InvocationInfo. Diese enthält interessante Informationen über den ausgelösten Fehler, wie beispielsweise dem Namen des Cmdlets, das den Fehler ausgelöst hat, und die Zeilennummer innerhalb der Cmdlet-Instanz. Gerade bei einem längeren Skript sind diese Informationen sehr hilfreich.Eine andere Möglichkeit, für ein Cmdlet Informationen an die Ausführungsumgebung zu übertragen, bieten die beiden Methoden WriteDebug und WriteVerbose. Diese stellen in gewisser Weise eine Sonderrolle dar, denn es ist der Benutzer, der mit Parametern der Befehlszeile steuert, ob diese Informationen angezeigt werden. Ein Beispiel ist die Eingabe folgender Befehlszeile:
<span class="hljs-keyword">Add</span><span class="bash">-Contact <span class="hljs-string">"Martina"</span> <span class="hljs-string">"Mustermann"</span> -Debug </span>
Hierbei werden dem Benutzer alle Texte angezeigt, die das Cmdlet über die Methode WriteDebug ausgibt. Ohne Angabe von Debug bleiben diese Informationen dem Benutzer verborgen. Die Steuerung übernimmt die PowerShell, sodass das Cmdlet nicht selbst unterscheiden muss, ob der Parameter gesetzt wurde oder nicht. Das Gleiche gilt auch für die Methode WriteVerbose. Hier heißt der Parameter Verbose. Aus technischer Sicht besteht kein Unterschied zwischen beiden Verfahren. Der Unterschied liegt allein in ihrer Semantik. Mit dem Parameter Debug werden Ausgaben erwartet, die eher technischer Natur sind. Der Name deutet bereits an, dass die Adressaten dieser Informationen Entwickler sind, die beispielsweise mit dem Status einer Datenbankverbindung etwas anfangen können. Wenn hingegen der Parameter Verbose angegeben wird, sind eher allgemeine Informationen über die Ausgabe gefragt, mit denen auch ein fortgeschrittener Anwender etwas wird anfangen können. Beide Informationstypen sind dazu gedacht, Fehler im Zusammenhang mit der Ausführung eines Cmdlets zu finden. Mit den Parametern Verbose und Debug kann der Anwender spezifische Detailinformationen erhalten, und die Umsetzung eines Cmdlets zeugt von Professionalität, wenn diese beiden Parameter unterstützt werden.Die beiden noch verbliebenen Möglichkeiten eines Cmdlets, um Informationen an die Ausführungsumgebung zu übertragen, sind WriteWarning und WriteInformation. Die Methode WriteWarning unterscheidet sich von den beiden zuvor bereits erläuterten Varianten dadurch, dass der Anwender diese Meldung weder unterdrücken noch explizit einfordern kann. Der Text wird in jedem Fall und farblich exponiert, also in einer anderen Textfarbe als der sonstige Text ausgegeben.In der Reihe der Methoden zur Kommunikation mit der Ausführungsumgebung nimmt die Methode WriteInformation eine etwas sonderbare Stellung ein. Sonderbar deshalb, weil die so übermittelten Informationen für den Anwender, der das Skript ausführt, zumindest in der aktuellen Version der PowerShell nirgends einsehbar sind. Sowohl die PowerShell ISE als auch die Befehlszeile zeigen die Informationen nicht an. Da sie jedoch über das PowerShell-API ermittelt werden können, sind sie für die Entwicklung eines Cmdlets sehr hilfreich. Im Unterschied zu den vier anderen Ausgabemethoden, die jeweils eine Zeichenkette erwarten, kann der Methode WriteInformation ein Objekt übergeben werden. Da sich damit beliebige Daten übermitteln lassen, sind den Analyseformen keine Grenzen gesetzt.Hübsches Beiwerk ist der zusätzliche Parameter Tags, mit dem Stichwörter mitgegeben werden können, um das jeweilige Ergebnis besser gruppieren zu können, sollte eine Anzeige das je unterstützen.Listing 3 zeigt die Klasse ScriptExecuter mit allen bislang vorgestellten Möglichkeiten zur Interaktion mit der Ausführung eines PowerShell-Skripts. Vor der Ausführung desselben stehen die Bereitstellung einer konfigurierten Ausführungsumgebung sowie die Konfiguration von verwendeten Assemblies und Modulen. Nach der Ausführung wiederum werden die beschriebenen Informationen eingesammelt und in das Ausführungsergebnis als eine Instanz des Typs ExecutionResult transportiert.
Listing 3: ScriptExecuter
public class ScriptExecuter : MarshalByRefObject <br/>{ <br/> #region Properties <br/> public List&lt;Assembly&gt; Assemblies { get; <span class="hljs-built_in">set</span>; } <br/> public List&lt;string&gt; <span class="hljs-keyword">Modules</span> { get; <span class="hljs-built_in">set</span>; } <br/> public Dictionary&lt;string, object&gt; <span class="hljs-keyword">Variables</span> <br/> { get; <span class="hljs-built_in">set</span>; } <br/> public HostCommunicationAdapter CommunicationAdapter <br/> { get; <span class="hljs-built_in">set</span>; } = null; <br/> public bool AutoInspectVariables <br/> { get; <span class="hljs-built_in">set</span>; } = true; <br/> #endregion <br/><br/> public ExecutionResult Execute(string script) { <br/> ExecutionResult runscriptresult = <br/> new ExecutionResult(script); <br/> <span class="hljs-built_in">using</span> (PowerShell ps = PowerShell.Create()) <br/> <span class="hljs-built_in">using</span> (Runspace rs = <br/> RunspaceFactory.CreateRunspace(new <br/> TestEnvironmentHost(CommunicationAdapter,<br/> runscriptresult.AddHostOutput))) <br/> { <br/> rs.<span class="hljs-keyword">Open</span>(); <br/> // bestehende Variablen und deren <br/> // Werte übertragen<br/> <span class="hljs-keyword">Variables</span>?.Keys.ToList().ForEach(variablename =&gt; <br/> { <br/> rs.SessionStateProxy.SetVariable( <br/> variablename, <span class="hljs-keyword">Variables</span>[variablename]); <br/> }); <br/> <span class="hljs-keyword">if</span> (AutoInspectVariables) { <br/> var <span class="hljs-built_in">pattern</span> = $@<span class="hljs-string">"\$\b(?&lt;item&gt;\w+)\b"</span>; <br/> Regex regex = new Regex(<span class="hljs-built_in">pattern</span>); <br/> var matches = regex.Matches(script); <br/> <span class="hljs-keyword">if</span> (matches.Count &gt; <span class="hljs-number">0</span>) { <br/> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">Variables</span> == null) <br/> <span class="hljs-keyword">Variables</span> = <br/> new Dictionary&lt;string, object&gt;(); <br/> foreach (Match m <span class="hljs-built_in">in</span> matches) <br/> { <br/> string key = m.Value.Substring(<span class="hljs-number">1</span>); <br/> <span class="hljs-keyword">if</span> (!<span class="hljs-keyword">Variables</span>.ContainsKey(key)) <br/> <span class="hljs-keyword">Variables</span>.<span class="hljs-keyword">Add</span>(key, null); <br/> } <br/> } <br/> } <br/> <br/> // Modifizierte Ausführungsumgebung hinzufügen <br/> ps.Runspace = rs; <br/> <br/> // Hinzufügen der zusätzlichen Assemblies <br/> Assemblies?.ForEach(assembly =&gt; <br/> { <br/> ps.AddCommand(<span class="hljs-string">"Import-Module"</span>).AddArgument( <br/> new Uri(assembly.CodeBase).LocalPath); <br/> }); <br/> <br/> // Hinzufügen zusätzlicher <span class="hljs-keyword">Module</span> <br/> <span class="hljs-keyword">Modules</span>?.ForEach(module =&gt; <br/> { <br/> ps.AddCommand(<span class="hljs-string">"Import-Module"</span>).AddArgument( <br/> module); <br/> }); <br/><br/> // Das eigentliche Skript hinzufügen <br/> ps.AddScript(script); <br/> var result = ps.Invoke(); <br/><br/> // Überführung des Ergebnisses <br/> runscriptresult.Errors.AddRange( <br/> ps.Streams.Error); <br/> runscriptresult.Warnings.AddRange( <br/> ps.Streams.Warning); <br/> runscriptresult.Debugs.AddRange( <br/> ps.Streams.Debug); <br/> runscriptresult.Verboses.AddRange( <br/> ps.Streams.<span class="hljs-keyword">Verbose</span>.Select(v =&gt; v.Message)); <br/> runscriptresult.Informations.AddRange( <br/> ps.Streams.Information); <br/> runscriptresult.Output.AddRange( <br/> result.Where(p =&gt; p != null &amp;&amp; <br/> p.BaseObject != null) <br/> .Select(p =&gt; p.BaseObject)); <br/><br/> // Auslesen der Variablen <br/> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">Variables</span> != null) { <br/> runscriptresult.<span class="hljs-keyword">Variables</span> = <br/> new Dictionary&lt;string, object&gt;(<span class="hljs-keyword">Variables</span>); <br/> runscriptresult.<span class="hljs-keyword">Variables</span>.Keys.ToList() <br/> .ForEach(variablename =&gt; <br/> runscriptresult.<span class="hljs-keyword">Variables</span>[ <br/> variablename] = rs.SessionStateProxy <br/> .GetVariable(variablename)); <br/> } <br/> rs.<span class="hljs-keyword">Close</span>(); <br/> } <br/> <span class="hljs-keyword">return</span> runscriptresult; <br/> } <br/>}
Die bisherigen Wege zum Ermitteln von Informationen sind sehr geradlinig und direkt. Das Cmdlet schreibt sie auf verschiedenen Wegen in die Ausführungsumgebung, die anschließend dort ausgelesen und je nach Anwendungsfall verarbeitet werden, wie der folgende Testfall zeigt:
[Fact]
public void AddContactWithDebugInformation()
{
var script = $@"
Add-Contact 'Martina' 'Mustermann' -Debug
";
var result = RunScript(script);
Assert.Contains("Schließe Datenbankverbindung",
result.Debugs.Select(d => d.Message));
}
Das Powershell-API bietet mit dem Zugriff auf Variablen noch eine weitere Möglichkeit, um an Informationen zu gelangen, die während der Ausführung verarbeitet werden. Diese Informationen sind gerade für längere Skripte mit vielen Zwischenschritten sehr hilfreich. Allerdings sind diese Informationen nicht vergleichbar mit dem Komfort, den man in Visual Studio mit der Inspektion von Variablen kennt. Über das PowerShell-API liegt lediglich der letzte Wert der Variablen vor, Wechsel des Inhalts verlaufen stillschweigend.Listing 4 zeigt ein Beispiel für einen Testfall, in dem eine Variable ausgelesen und überprüft wird. Wichtig dabei ist, dass die Variable im Vorfeld bekannt sein muss, da man die PowerShell, wie in der Klasse ScriptExecuter gezeigt, nur nach dem Inhalt einer spezifischen Variablen fragen kann und man keine Möglichkeit hat, durch die Variablen zu iterieren.
Listing 4: Testfall mit Zugriff auf Variablen
[Fact] <br/>public void ReadAndWriteVariables() <br/>{ <br/> var script = $@" <br/> $one = 10 <br/> $two = 20 <br/> $three = $one + $two <br/> "; <br/><br/> var result = RunScript(script); <br/><br/> Assert.True(result.Variables.ContainsKey("one")); <br/> Assert.True(result.Variables.ContainsKey("two")); <br/> Assert.True(result.Variables.ContainsKey(<br/> "three")); <br/><br/> Assert.Equal(10, (int)result.Variables["one"]); <br/> Assert.Equal(20, (int)result.Variables["two"]); <br/> Assert.Equal(30, (int)result.Variables["three"]); <br/>}
Die Klasse ScriptExecuter löst das Problem dadurch, dass das Skript im Vorfeld mithilfe einer Regular Expression analysiert wird und hier die im Skript verwendeten Variablen ausgelesen werden.
Ausführung mit Host
Die bisherigen Beispiele zeigen einen Zugriff auf das PowerShell-API, der nach einem einfachen Muster durchgeführt wird. Es wird ein Skript ausgeführt und anschließend werden die Ausgaben und Ergebnisse auf verschiedenen Wegen ermittelt und ausgewertet.Um jedoch reale Szenarien besser abbilden zu können, fehlt der wichtige Bereich der Interaktion mit dem Anwender. Die PowerShell ist eine Skriptsprache, die von Grund auf darauf ausgerichtet ist, über eine textbasierte Befehlszeile mit dem Anwender in Verbindung zu treten. Neben dem Standardparameter Debug oder ausgegebenen Warnungen gibt es zahlreiche Beispiele, wie die PowerShell selbst oder aber die jeweiligen Cmdlets den Anwender zur Eingabe von Text oder zur Auswahl einer Option auffordern.Diese Interaktion verantwortet ein von der PowerShell getrenntes Modul, der sogenannte Host. Dieser Host ist dafür verantwortlich, dass die jeweilige Anfrage in ein spezifisches Verhalten mit einer spezifischen Oberfläche umgesetzt wird. In den bisherigen Beispielen wurde die Anwesenheit eines Hosts vernachlässigt, weil für die jeweiligen Testfälle keine Interaktion mit dem Anwender notwendig war. Auch wenn die PowerShell jede Form von Host zulässt, ist sie doch stark auf die Konsole ausgerichtet. Das zeigen einige Methoden, der die Vordergrund- und Hintergrundfarbe übergeben werden, um so beispielsweise zwischen Warnung und allgemeiner Information zu unterscheiden. Würde man eine Hostumgebung für eine Desktop-Anwendung entwickeln wollen, könnte man diese Parameter ignorieren beziehungsweise eine andere Form dafür finden.Für die testgetriebene Entwicklung stellt der Host die Verbindung zwischen der Ausführung des Skripts und der Testumgebung her. Da bei einem automatisierten Test kein Benutzer vor dem Rechner sitzen kann, der geduldig darauf wartet, dass er eine von mehreren Optionen auswählen oder Text eingeben kann, muss der Host eine Möglichkeit anbieten, die Interaktion in eine Form zu überführen, die in einem Testfall verwendet werden kann.Die in Listing 3 bereits vorgestellte Klasse ScriptExecuter verwendet bereits den hier entwickelten Host. Dort wird der Host beim Erstellen einer Ausführungsumgebung in Form einer Instanz der Klasse TestEnvironmentHost übergeben. Parameter für den Konstruktor sind einerseits die Klasse HostCommunicationAdapter für die Übermittlung der angewendeten Eingabemethoden und andererseits eine Methode, über die die Ausgaben des Hosts geleitet werden können. Benötigt die PowerShell eine Eingabe von Text, so wird im Host die Methode Prompt aufgerufen, die den Aufruf an den Adapter und über diesen an den Testfall weiterleitet. Dort ist eine Callback-Funktion hinterlegt, die der Testfall als Parameter der Methode RunScript übergeben kann. Darin kann dann je nach Anforderung und Testsituation reagiert werden.Sowohl für die PowerShell selbst als auch für das Cmdlet ist dieses Verhalten vollkommen transparent. Das bedeutet, dass weder das Cmdlet noch die PowerShell-Ausführungsumgebung erkennen kann, dass es sich um eine Testumgebung handelt (Listing 5).Listing 5: PowerShell-Host für die Interaktion mit Testfällen
internal class TestEnvironmentHost : PSHost <br/>{ <br/> public TestEnvironmentHost( <br/> HostCommunicationAdapter communicationAdapter, <br/> Action&lt;string&gt; hostOutput) <br/> { <br/> _testEnvironmentUserInterface = <br/> new TestEnvironmentUserInterface( <br/> communicationAdapter, hostOutput); <br/> } <br/> private readonly TestEnvironmentUserInterface <br/> _testEnvironmentUserInterface ; <br/><br/> public override PSHostUserInterface UI { <br/> get { return this._testEnvironmentUserInterface;} <br/> } <br/>} <br/><br/>internal class TestEnvironmentUserInterface : <br/> PSHostUserInterface <br/>{ <br/> private readonly HostCommunicationAdapter <br/> _communicationAdapter; <br/> private readonly Action&lt;string&gt; _hostOutput; <br/><br/> internal TestEnvironmentUserInterface( <br/> HostCommunicationAdapter communicationAdapter, <br/> Action&lt;string&gt; hostOutput) <br/> { <br/> _communicationAdapter = communicationAdapter ?? <br/> throw new ArgumentNullException( <br/> nameof(communicationAdapter)); <br/> _hostOutput = hostOutput; <br/> } <br/><br/> public override Dictionary&lt;string, PSObject&gt; <br/> Prompt(string caption, string message, <br/> Collection&lt;FieldDescription&gt; descriptions) <br/> { <br/> Dictionary&lt;string, PSObject&gt; results = <br/> new Dictionary&lt;string, PSObject&gt;(); <br/><br/> if (_communicationAdapter == null) return null; <br/><br/> foreach (FieldDescription fd in descriptions) <br/> { <br/> _hostOutput?.Invoke(fd.Name); <br/> string userData = _communicationAdapter <br/> .PromptForValue(fd.Name); <br/> if (userData == null) { return null; } <br/><br/> results[fd.Name] = <br/> PSObject.AsPSObject(userData); <br/> } <br/> return results; <br/> } <br/><br/> public override int PromptForChoice(string caption, <br/> string message, Collection&lt;ChoiceDescription&gt; <br/> choices, int defaultChoice) <br/> { <br/> return _communicationAdapter.OnPromptForChoice( <br/> caption, message, choices, defaultChoice); <br/> } <br/><br/> public override void Write(string value) { <br/> _hostOutput?.Invoke(value); <br/> } <br/><br/> public override void Write( <br/> ConsoleColor foregroundColor, <br/> ConsoleColor backgroundColor, <br/> string value) <br/> { <br/> _hostOutput?.Invoke(value); <br/> } <br/><br/> public override void WriteDebugLine(string message) <br/> { <br/> _hostOutput?.Invoke($"DEBUG: {message}"); <br/> } <br/> public override void WriteErrorLine(string value) <br/> { <br/> _hostOutput?.Invoke($"ERROR: {value}"); <br/> } <br/> public override void WriteVerboseLine( <br/> string message) <br/> { <br/> _hostOutput?.Invoke($"VERBOSE: {message}"); <br/> } <br/> public override void WriteWarningLine( <br/> string message) <br/> { <br/> _hostOutput?.Invoke($"WARNING: {message}"); <br/> } <br/>}
Listing 6 zeigt ein einfaches Beispiel für die Verwendung dieses Verfahrens. Das Skript besteht lediglich aus drei Standardbefehlen. Mit Read-Host wird der Benutzer zur Eingabe aufgefordert, und mit Write-Output werden diese Daten über die Variable name als Ausgabe wieder an die PowerShell übergeben. Der Befehl Write-Host hingegen leitet die Ausgabe direkt an den Host weiter, wo auch sie eingesammelt und der Auswertung zur Verfügung gestellt wird. Der Methode RunScript wird eine Callback-Funktion übergeben, die im Testfall selbst definiert worden ist. Hier wird anhand des Textes, der den Anwender zur Eingabe eines Namens auffordert, unterschieden, welcher Wert zurückgeliefert wird. Nach der Ausführung erfolgt dann die Überprüfung des Ergebnisses. Das ist zum einen der Zustand der Variable name, sowie zum anderen, ob in der PowerShell-Ausführungsumgebung die gesuchte Ausgabe vorliegt.
Listing 6: Testfall mit simulierter Benutzerinteraktion
public void ReadTextFromHost() <br/>{ <br/> const string enteryourname = <br/> "Bitte gib deinen Namen ein"; <br/><br/> string fnPrompt(string message) <br/> { <br/> switch (message) <br/> { <br/> case enteryourname: <br/> return "Martina"; <br/> default: <br/> return null; <br/> } <br/> } <br/><br/> var script = $@" <br/> $name = Read-Host '{enteryourname}' <br/> Write-Host $name <br/> Write-Output $name <br/> "; <br/> var result = RunScript(script, <br/> promptForValueFunc: fnPrompt); <br/><br/> Assert.Equal("Martina", <br/> result.Variables["name"].ToString()); <br/> Assert.Contains("Martina", result.HostOutput); <br/> Assert.Contains("Martina", <br/> result.Output.OfType&lt;string&gt;()); <br/>}
In diesem kleinen Beispiel wurden Write-Output und Write-Host nebeneinander verwendet, um noch einmal den Unterschied zwischen beiden erläutern zu können, da dieser auch für die testgetriebene Entwicklung Relevanz hat. Das Standard-Cmdlet Write-Output ist funktional identisch mit der Methode WriteOutput, die ein Cmdlet verwendet, um Rückgabewerte zu liefern oder sonstige funktionale Ausgaben hin zur PowerShell-Ausführungsumgebung zu machen. Dieser Befehl und diese Methode stehen immer zur Verfügung und haben keine Abhängigkeiten.Der Befehl Write-Host ist ebenfalls ein Standardbefehl der PowerShell, für den es jedoch keine Entsprechung als Methode im Cmdlet gibt. Das Cmdlet kann damit keine Methode verwenden, um Ausgaben explizit an den Host weiterzuleiten. Es gibt zwar Möglichkeiten, mit denen ein Cmdlet diese Aus- und Eingaben in direkter Kommunikation vornehmen kann, diese sollen aber nicht Gegenstand dieses Artikels sein. Der Befehl Write-Host aus dem Skript funktioniert nur dann, wenn es einen Host gibt. Wenn die PowerShell so elementar ausgeführt wird, wie es die Beispiele am Anfang dieses Artikels zeigten, und damit kein Host zur Verfügung steht, wird dieser Befehl mit einer Fehlermeldung quittiert. Das ist nachvollziehbar, da nichts auf einem Host ausgegeben werden kann, der nicht zur Verfügung steht. Diese Unterscheidung ist relevant, wenn man die Wege nachzeichnen möchte, die Debug-Informationen, Fehler und Warnungen gehen. Zum einen landen sie wie beschrieben als ein Datensatz in der Ausführungsumgebung. Steht ein Host zur Verfügung, werden sie zusätzlich dort ausgegeben. Die Klasse TestEnvironmentUserInterface aus Listing 5 zeigt einige Methoden, die diese Ausgaben vornehmen. Sie sind in der Basisklasse PSHostUserInterface definiert und werden hier überschrieben. So werden mit WriteVerboseLine, WriteDebugLine und so weiter entsprechende Ausgaben vorgenommen. Ein klassischer PowerShell-Host gibt diese Daten auf der Befehlszeile aus. In der Testumgebung hingegen werden sie wie beschrieben über eine Callback-Funktion an das Ergebnis der Ausführung weitergereicht, sodass auch diese Daten dort ausgewertet werden können.Ein letzter Testfall soll eine weitere Variante der Interaktion mit dem Benutzer demonstrieren und damit das Portfolio der Möglichkeiten testgetriebener Entwicklung abrunden. Listing 7 zeigt einen Fall, bei dem der Benutzer nichts einzugeben hat, sondern eine von mehreren angebotenen Optionen wählen muss.
Listing 7: Testfall für die Auswahl einer Option
public void InvokeSomeException() <br/>{ <br/> var errorMessage = "Eine Fehlermeldung"; <br/> <br/> Func&lt;string, string, <br/> Collection&lt;ChoiceDescription&gt;, int, int&gt; <br/> choiceFunc = delegate (<br/> string caption, string message, <br/> Collection&lt;ChoiceDescription&gt; <br/> choices, int defaultChoice) <br/> { <br/> if (message.Equals(errorMessage)) { <br/> return 2; <br/> } <br/> return defaultChoice; <br/> }; <br/><br/> var script = $@" <br/> Invoke-Exception '{errorMessage}' <br/> -ErrorAction Inquire <br/> "; <br/><br/> // Es wird eine Fehlermeldung geworfen, <br/> // wenn der Anwender die Ausführung abbricht. <br/> Assert.Throws&lt;ActionPreferenceStopException&gt;(() =&gt; <br/> RunScript(script, promptForChoice: choiceFunc)); <br/>}
Das Skript ist denkbar einfach gehalten. Es besteht lediglich aus einem einzigen Befehl. Invoke-Exception ist ein Cmdlet aus dem Testprojekt, das nichts weiter tut, als einen Fehler an die PowerShell-Ausführungsumgebung zu übertragen. Ausschlaggebend für die sich daraus ergebende Auswahl einer Option ist hingegen der Standardparameter ErrorAction. Damit kann der Anwender festlegen, was in einem Fehlerfall passieren soll. Dieser Parameter ist ausgesprochen hilfreich, weil von Cmdlets geworfene Ausnahmen im Gesamtkontext berücksichtigt werden können und nicht zum Abbruch eines Skripts führen müssen. So löst beispielsweise das Cmdlet Get-LocalUser eine Ausnahme aus, wenn der gesuchte Account nicht existiert. Wenn man jedoch anhand dieser Informationen eine Folgeaktion durchführen möchte, ist die Fehlermeldung eher hinderlich. Mit dem Parameter ErrorAction kann man festlegen, was in einem solchen Fall passieren soll. Die Option SilentlyContinue legt beispielsweise fest, dass das Skript ohne die Ausgabe einer Fehlermeldung fortgesetzt werden soll, während Stop die Ausführung umgehend abbricht. Mit Inquire wird der Benutzer gefragt, was in diesem Fall zu tun ist. Um auch diese Situation testen zu können, wird die Abfrage an den Host wieder an den Adapter für die Interaktion mit der Testumgebung weitergeleitet und darüber anschließend an den Testfall weitergereicht.Im Gegensatz zur Eingabe von Daten ist die Auswahl einer Option durch den Benutzer etwas komplizierter. Hier stehen eine prinzipiell offene Anzahl von Optionen zur Verfügung, und man kann nur für den Einzelfall entscheiden, welche Option auszuwählen ist. Der Testfall in Listing 7 wählt mit dem Wert 2 die Option Abbruch. Daraufhin löst die PowerShell-Ausführungsumgebung eine Ausnahme aus, die dann entsprechend einer formulierten Erwartungshaltung überprüft werden kann.
Fazit
Das PowerShell-SDK erlaubt die testgetriebene Entwicklung von Cmdlets auf einem qualitativ hohen Niveau. Schon mit wenigen Mitteln lassen sich Tests schreiben, und mit etwas mehr Aufwand kann nahezu das gesamte Spektrum der PowerShell-Möglichkeiten automatisierten Tests unterzogen werden. Die hier gezeigten Beispiele wurden unter Wahrung der funktionalen Konsistenz auf das Wesentliche reduziert, um den jeweiligen Sachverhalt zu veranschaulichen und dabei den Artikel nicht mit Programmcode zu überladen. Die vollständigen Quelltexte sind unter [3] zu finden.Fussnoten
- Test- und Mock-Framework Pester, https://github.com/pester/Pester
- Torsten Zimmermann, Einstieg in die PowerShell, Teil 1, Anwendungsautomation, dotnetpro 11/2018, Seite 8 ff., http://www.dotnetpro.de/A1811PSEinstieg
- GitHub-Seite zu diesem Artikel, http://www.dotnetpro.de/SL1811TestDriven1