Tips for writing your first compiled binary PowerShell modules

I recently completed work on a my first compiled binary PowerShell module– these are modules built with C#/.Net code instead of PowerShell code. A few module development basics like project setup, handling help files, and writing unit tests did take some work to figure out. In this article I provide some tips for how to handle these common scenarios to help you get started on new projects.

View the full example code

The abbreviated examples referenced in this post are from Archivial, an open source backup software project I’m working on. The full source can be viewed here: https://github.com/keithbabinec/Archivial.

Project setup basics

Compiled modules start with a class library project. Create a new class library project in Visual Studio and add a nuget reference to Microsoft.PowerShell.5.ReferenceAssemblies (this assumes you are targeting PowerShell 5.1 and not PowerShell Core).

Your module class library will contain one or more cmdlets. Which in this case is a class that derives from the System.Management.Automation.Cmdlet or System.Management.Automation.PSCmdlet base class. This guide has a pretty good overview with examples. You will end up making one class file per cmdlet.

Module manifest setup

Create your module manifest file somewhere in the Visual Studio project and set the file properties to copy to output automatically– this just helps so you don’t forget to package the .psd1 manifest file with your project output.

The main entry point (the root module) defined in the manifest will be the name of the class library project’s output DLL. As long as the module manifest points to the correct DLL that contains your cmdlets, the import-module should load correctly.

Example class library with a module manifest file
Example manifest file (partial contents)

You can load your module directly by calling import-module against the DLL output from your project, the manifest file (.psd1), or by name — if you have placed that path into your PSModulePath environment variable.

Example: Import-Module against your module output binary.

A compiled cmdlet example

The following is the shell of an example cmdlet from Archivial called Add-ArchivialLocalSource. I provide this example because it has some helpful modifications over some of the basic examples you might find on other docs:

  • The class inherits from a base cmdlet class to share common code (for example database connections, custom logging, etc).
  • The parameters are public properties that have the same validation attributes used in regular PowerShell functions.
  • The class has two constructors. The first (default/empty) constructor is for when the command is called in normal fashion from PowerShell. The second constructor allows for dependency injection for unit testing.
  • The class has help documentation defined inline (see later section in this post for more information).
/// <summary>
///   <para type="synopsis">Adds a local folder to the Archivial backup folders list.</para>
///   <para type="description">A Local Source is a folder on your computer (or a directly attached external drive) that you would like Archivial to backup and automatically monitor for new and updated files.</para>
///   <para type="description">The priority of the source determines how frequently it will be scanned for changes. The automated scanning schedule for Low priority sources is once every 48 hours. Medium priority sources are scanned every 12 hours. High priority sources are scanned every hour.</para>
///   <para type="description">The optional MatchFilter parameter allows you to narrow the scope of files in the folder to be monitored. For example, by file extension. Any windows file path wildcard expression will be accepted here.</para>
/// </summary>
/// <example>
///   <code>C:\> Add-ArchivialLocalSource -FolderPath "C:\users\test\documents" -Priority High -Revisions 3</code>
///   <para>Adds the specified folder to backup with high priority, and to retain up to 3 revisions of file history.</para>
///   <para></para>
/// </example>
/// <example>
///   <code>C:\> Add-ArchivialLocalSource -FolderPath "C:\users\test\music\playlists" -Priority High -Revisions 3 -MatchFilter *.m3u</code>
///   <para>Adds the specified folder to backup with high priority, but only files that match the wildcard extension filter.</para>
///   <para></para>
/// </example>
[Cmdlet(VerbsCommon.Add, "ArchivialLocalSource")]
public class AddArchivialLocalSourceCommand : BaseArchivialCmdlet
{
  /// <summary>
  ///   <para type="description">Specify the folder path that should be backed up and monitored.</para>
  /// </summary>
  [Parameter(Mandatory = true)]
  [ValidateNotNullOrEmpty]
  public string FolderPath { get; set; }
  
  /// <summary>
  ///   <para type="description">Specify the priority of this source (which determines how frequently it will be scanned for changes).</para>
  /// </summary>
  [Parameter(Mandatory = true)]
  [ValidateSet("Low", "Medium", "High")]
  public string Priority { get; set; }
  
  /// <summary>
  ///   <para type="description">Specify the maximum number of revisions to store in the cloud for the files in this folder.</para>
  /// </summary>
  [Parameter(Mandatory = true)]
  [ValidateRange(1, int.MaxValue)]
  public int Revisions { get; set; }
  
  /// <summary>
  ///   <para type="description">Optionally specify a wildcard expression to filter the files to be backed up or monitored.</para>
  /// </summary>
  [Parameter(Mandatory = false)]
  [ValidateNotNullOrEmpty]
  public string MatchFilter { get; set; }
  
  /// <summary>
  /// Default constructor.
  /// </summary>
  public AddArchivialLocalSourceCommand() : base() { }
  
  /// <summary>
  /// A secondary constructor for dependency injection.
  /// </summary>
  /// <param name="database"></param>
  public AddArchivialLocalSourceCommand(IClientDatabase database) : base(database) { }
  
  /// <summary>
  /// Cmdlet invocation.
  /// </summary>
  protected override void ProcessRecord()
  {
    // main invoke
    // add implementation code here
  }
}

Generate and deploy module help files

Compiled binary modules require an xml-based help file which is very tedious to write or edit by hand. Thankfully there are some tools out there that can generate this file automatically for you based on your inline XML code comments. I highly recommend XmlDoc2CmdletDoc to accomplish this task. The process works like this:

  • Set your VS project settings to output a comment XML file.
  • Add the XmlDoc2Cmdlet nuget to your project.
  • Add XML code comments to your cmdlets according to the XmlDoc2CmdletDoc instructions.

On project build the XmlDoc2Cmdlet automatically generates the required module help file (*.dll-Help.xml). When you distribute your module just make sure the help file is in the same folder as your other module binaries. When someone imports your module, this help file will automatically be loaded and then they can view cmdlet help.

Example: XmlDoc2Cmdlet auto-generated helpfile in module output folder

Select a test framework / methodology

There are multiple ways to test a binary module. First start by making a choice if you want to test your module using PowerShell code and the Pester framework or if you want to test using .NET code and a test framework like MSTest or NUnit.

Testing using PowerShell code is nice because you can test in the language that your module end-users will be using, and can easily handle good end-to-end scenarios.

Testing in a .NET is nice because you can get automatic test discovery / handling in Visual Studio and an Azure DevOps pipeline without custom build scripts. It also provides a way to invoke cmdlets directly without setting up a PowerShell pipeline/runspace. I ended up testing in .NET for Archivial.

Unit testing cmdlets in .NET

There were two types of tests that I found helpful for cmdlets in .NET. The first type of test is to ensure that the cmdlet parameters have the correct attributes assigned. These are simple pinning tests to ensure that you don’t accidentally remove or change one.

Example 1: Parameter attribute pinning tests

public class TypeHelpers
{
  public static bool CmdletParameterHasAttribute(Type cmdletType, string cmdletProperty, Type attribute)
  {
    var property = cmdletType.GetProperty(cmdletProperty);
    return Attribute.IsDefined(property, attribute);
  }
}

[TestClass]
public class AddArchivialLocalSourceCommandTests
{
  [TestMethod]
  public void AddArchivialLocalSourceCommand_FolderPathParameter_HasRequiredAttributes()
  {
    Assert.IsTrue(
      TypeHelpers.CmdletParameterHasAttribute(
        typeof(AddArchivialLocalSourceCommand),
        nameof(AddArchivialLocalSourceCommand.FolderPath),
        typeof(ParameterAttribute))
    );

    Assert.IsTrue(
      TypeHelpers.CmdletParameterHasAttribute(
        typeof(AddArchivialLocalSourceCommand),
        nameof(AddArchivialLocalSourceCommand.FolderPath),
        typeof(ValidateNotNullOrEmptyAttribute))
    );
  }
}

The second type of tests would concern the actual functionality of the cmdlet. To invoke a cmdlet inside .NET code, you create a new instance of your cmdlet class and then call .Invoke() directly on the object. In the example below I leverage that secondary constructor for dependency injection to support mocked dependencies that are helpful for unit testing. I highly recommend the Moq framework for test mocks.

Example 2: Cmdlet.Invoke() functionality test with mocked dependency injection

[TestMethod]
public void AddArchivialLocalSourceCommand_CanSaveNewLocalSource()
{
  // setup dependencies
  
  var mockedDb = new Mock<IClientDatabase>();
  
  LocalSourceLocation databaseCommitedObject = null;
  
  mockedDb.Setup(x => x.GetSourceLocationsAsync()).ReturnsAsync(new SourceLocations());
  
  mockedDb.Setup(x => x.SetSourceLocationAsync(It.IsAny<LocalSourceLocation>()))
    .Returns(Task.CompletedTask)
    .Callback<LocalSourceLocation>(x => databaseCommitedObject = x);
  
  // setup cmdlet with parameters
  
  var command = new AddArchivialLocalSourceCommand(mockedDb.Object)
  {
    FolderPath = "C:\\folder\\path",
    Priority = "Low",
    Revisions = 1,
    MatchFilter = "*"
  };
  
  // cmdlet invocation
  
  var result = command.Invoke().GetEnumerator().MoveNext();
  
  // verify results
  
  mockedDb.Verify(x => x.SetSourceLocationAsync(It.IsAny<LocalSourceLocation>()), Times.Once);
  
  Assert.IsNotNull(databaseCommitedObject);
  Assert.AreEqual("C:\\folder\\path", databaseCommitedObject.Path);
  Assert.AreEqual(FileBackupPriority.Low, databaseCommitedObject.Priority);
  Assert.AreEqual(1, databaseCommitedObject.RevisionCount);
  Assert.AreEqual("*", databaseCommitedObject.FileMatchFilter);
}

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 )

Google photo

You are commenting using your Google 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