For years all our PowerShell modules we released were script modules (i.e. they were written in PowerShell). However, it is certainly possible to write PowerShell modules in C# as binary modules. Of course there are already plenty discussions whether one or the other approach is better. It really depends on the exact requirements.
For us the limitations of Pester (especially the lack in support for mocking native .NET objects and methods) and in refactoring gave us enough reason to at least give binary modules a try. For a quick overview on how to create such modules fire up your favourite search engine and look for c# write powershell module
.
However, built-in support for testing and invoking PSCmdlet
s (not Cmdlet
s) is scarce. There is no PowerShell.Invoke('Get-Process')
that we could use with our tested and approved set of Assert
statements.
We therefore extended our biz.dfch.CS.Testing module to facilitate some common functions when developing and testing PSCmdlets, which I will quickly described here:
Generic Invocation with Results
We created a wrapper that allows you to invoke a PSCmdlet
based on its type with arbitrary parameters:
var parameters = @"-RequiredStringParameter 'arbitrary-value'"; var results = PsCmdletAssert.Invoke(typeof(MyCmdlet), parameters); Assert.IsNotNull(results); Assert.AreEqual(1, results.Count); Assert.AreEqual("expected", results[0]);
The PowerShell equivalent would look like this:
PS > Invoke-MyCmdlet -RequiredStringParameter 'arbitrary-value';
The Cmdlet name is derived from its custom CmdletAttribute
and the result is converted into a list of native objects (converted from the original PSObject
s). From there you can continue with your preferred testing library (MSTest, NUnit, …).
Note: the pipeline and its runspace are re-created for every invocation. This might not really speed up things but at least gives you a fresh environment on very run.
Exceptions
The generic [ExpectedException(typeof(Exception)]
does not give us much when we really want to examine the contents of the exception – especially when the exception does not have a public constructor such as ParameterBindingValidationException
.
We therefore created some custom annotations that help you deal with some commonly found exceptions:
ParameterBindingValidationException
Whenever you invoke a Cmdlet that would generate a parameter validation exception (such as ‘no empty strings allowes’) a ParameterBindingValidationException
is thrown where we can use a regex to inspect its Message
property:
[TestMethod] [ExpectParameterBindingValidationException(MessagePattern = "RequiredStringParameter")] public void InvokeWithEmptyStringPropertyValueThrowsParameterBindingValidationException() { var parameters = @"-RequiredStringParameter ''"; PsCmdletAssert.Invoke(typeof(MyCmdlet), parameters); }
ParameterBindingException
The same can be used for ParameterBindingException
s:
[TestMethod] [ExpectParameterBindingException(MessagePattern = @"'Credential'.+.System\.String.")] public void InvokeWithMissingiParameterThrowsParameterBindingException2() { var parameters = @"-Uri http://localhost -Credential arbitrary-user-as-string"; var results = PsCmdletAssert.Invoke(typeof(EnterServer), parameters); }
The Message
of the exception is matched as a regex against the MessagePattern
.
Note: in general a Message
is localised, so be careful when matching language or regional specific contents.
General Exceptions
Sometimes we want to catch any exception and examine its contents, so it might be useful to pass an exceptionHandler
to our Cmdlet invocation method.
The Func
we pass on to PsCmdletAssert.Invoke
is invoked if the Cmdlet throws an exception. The exception will always be a CmdletInvocationException with an InnerException
holding the actual exception. Therefore the Func
just gets the InnerException
and can act upon it. The returned exception is then passed to the test runner. The reason for this is that we can safely detect if an exception was thrown and our handler was really invoked..
[TestMethod] [ExpectedException(typeof(ArgumentException))] public void InvokeWithValue42ThrowsArgumentException() { var value = 42; var parameters = string.Format(@"-RequiredIntParameter {0}", value); Func<Exception, Exception> exceptionHandler = exceptionThrown => { Assert.IsInstanceOfType(exceptionThrown, typeof(ArgumentException)); Assert.IsTrue(exceptionThrown.Message.Contains("RequiredIntParameter")); Assert.IsTrue(exceptionThrown.Message.Contains("Invalid int value")); return exceptionThrown; }; var results = PsCmdletAssert.Invoke(typeof(TestPsCmdletBehaviour), parameters, exceptionHandler); Assert.IsNotNull(results); Assert.AreEqual(1, results.Count); var result = results[0].BaseObject; Assert.IsTrue(result is long); Assert.AreEqual((long) value * 2, result); PsCmdletAssert.HasOutputType(typeof(TestPsCmdletBehaviour), result.GetType(), TestPsCmdletBehaviour.ParametersSets.VALUE); }
ErrorRecords
Cmdlets can write to the Error pipeline generating zero or more ErrorRecord
s. If we want to make sure a Cmdlet does not create an ErrorRecord
or creates an ErrorRecord
with a specific contents, we can invoke an errorHandler
. That handler is an Action<IList>
which gets a list of all error records created during invocation of the Cmdlet:
[TestMethod] public void InvokeWithValue15ReturnsLongAndGeneratesErrorRecord() { var value = 15; var parameters = string.Format(@"-RequiredIntParameter {0}", value); Action<IList<ErrorRecord>> errorHandler = errorRecords => { Assert.AreEqual(1, errorRecords.Count); var errorRecord = errorRecords[0]; Assert.AreEqual(value, (int) errorRecord.TargetObject); Assert.IsInstanceOfType(errorRecord.Exception, typeof(ArgumentException)); return; }; var results = PsCmdletAssert.Invoke(typeof(TestPsCmdletBehaviour), parameters, null, errorHandler); Assert.IsNotNull(results); Assert.AreEqual(1, results.Count); var result = results[0].BaseObject; Assert.IsTrue(result is long); Assert.AreEqual((long) value * 2, result); }
Note: by default (depending on the -ErrorAction
parameter) a Cmdlet will still return output even if it created an ErrorRecord
. So we can still examine its results from PsCmdletAssert.Invoke
.
Output Types
Cmdlets that specify an OutputTypeAttribute
(for all, one or multiple parameter sets) should behave and return types according to the defined output type. As a Cmdlet can return any type (regardless of its OutputType
definition) we might want to validate and assert for that:
var value = 15; var parameters = string.Format(@"-RequiredIntParameter {0}", value); var results = PsCmdletAssert.Invoke(typeof(TestPsCmdletBehaviour), parameters); var result = results[0].BaseObject; // Assert.IsTrue(result is long); PsCmdletAssert.HasOutputType(typeof(TestPsCmdletBehaviour), result.GetType(), "myParameterSet");
The result
of the Cmdlet is defined to return a long
according to its OutputTypeAttribute
for a parameter set named myParameterSet
. By using PsCmdletAssert.HasOutputType
we assert that this is really the case.
Aliases
Every Cmdlet can define zero or more aliases. As we do not want to test them by invocation but maybe merely verify its existence we can assert that in the following way:
[TestMethod] public void TestAliasesSucceeds() { PsCmdletAssert.HasAlias(typeof(TestPsCmdletBehaviour), "Test-PsCmdletBehaviourWithAnAlias1"); PsCmdletAssert.HasAlias(typeof(TestPsCmdletBehaviour), "Test-PsCmdletBehaviourWithAnAlias2"); }
Conclusion
Developing and testing C# binary PowerShell modules is much more type safe and opens us the whole range of C# based tooling and testing frameworks. For us this especially comes in handy when dealing with native .NET objects (such as DataServiceContext
which are – as of now – impossible to mock in plain PowerShell/Pester).
Other benefits such as refactoring support and speed might count for their own depending on the use case.
Happy testing!