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 PSCmdlets (not Cmdlets) 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 PSObjects). 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 ParameterBindingExceptions:

[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 ErrorRecords. 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&lt;IList&gt; 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!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.