Skip to content

Blog

Setting up an Arrange, Act, Assert comment template in Visual Studio 2022

To add a simple code snippet that allows a set of arrange, act and assert comments to be inserted using the short code of aaa, firstly, create a simple XML file with the following contents and save it as ArrangeActAssert.snippet:

<?xml version="1.0" encoding="utf-8"?>
<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <CodeSnippet Format="1.0.0">
        <Header>
            <Title>Arrange Act Assert</Title>
            <Author>Neil Docherty</Author>
            <Description>Adds an arrange, act, assert to a test</Description>
            <HelpUrl />
            <Keywords />
            <SnippetTypes />
            <Shortcut>aaa</Shortcut>
        </Header>
        <Snippet>
            <Declarations />
            <References />
            <Code Kind="any" Language="CSharp"><![CDATA[// Arrange
$end$

// Act


// Assert]]></Code>
        </Snippet>
    </CodeSnippet>
</CodeSnippets>

Then, to import it into Visual Studio, perform the following steps:

  1. Go to the Tools menu
  2. Select Code Snippets Manager
  3. Choose the Import button and locate and select your ArrangeActAssert.snippet file and click Open.

Now, when editing some C# code, typing aaa and hitting tab twice will create a block of text similar to:

// Arrange


// Act


// Assert

HTTP Client Factory (IHttpClientFactory) mock using NSubstitute

Following the controversy around Moq, I decided to look into using NSubstitute as an alternative mocking library.

The example below is an NSubstitute version of the class created as part of my C# TDD blog article. The only change in functionality is the optional passing in of an HTTP status code.

public static IHttpClientFactory GetStringClient(string returnValue, HttpStatusCode returnStatusCode = HttpStatusCode.OK)
{
    var httpMessageHandler = Substitute.For<HttpMessageHandler>();
    var httpClientFactory = Substitute.For<IHttpClientFactory>();

    httpMessageHandler
        .GetType()
        .GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)
        .Invoke(httpMessageHandler, new object[] { Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>() })
        .Returns(Task.FromResult(new HttpResponseMessage(returnStatusCode) { Content = new StringContent(returnValue) }));

    HttpClient httpClient = new(httpMessageHandler);
    httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

    return httpClientFactory;
}

Changing branch used by Flux deployment

If the need arises to change the branch used by a Flux installation, this can be done without bootstrapping the cluster again. Not that this is recommended only on similar setups (e.g. you’re trying out a new change on a dev cluster and want to point to your dev branch which is based on the default branch).

Components

This article assumes the following components are in play:

  • Kubernetes cluster called cluster01
  • Flux mono repo with the cluster definition in the locations clusters/development (this folder contains the flux-system folder)
  • A default branch called main
  • A working branch called 12345-something-to-test that is based on main with a new change in it

Process

  1. Switch to your working branch
  2. Run the following command at a shell prompt (Bash, PowerShell, etc…) when the current context is you the target cluster: flux suspend source git flux-system
  3. Update clusters/development/flux-system/gotk-sync.yaml to set the value of branch to 12345-something-to-test and commit and push it
  4. Within your Kubernetes cluster, update the resource of type GitRepository named flux-system in the flux-system namespace so the branch field is also 12345-something-to-test
  5. Run the following command at a shell prompt (Bash, PowerShell, etc…) when the current context is you the target cluster: flux resume source git flux-system

Once testing is complete, repeat the above process but setting the branch back to main.

.NET Container Running as Non-Root in Kubernetes

This is a quick guide on how to get a standard .NET container running as a non-root, non privileged user in Kubernetes. It is not a complete security guide but rather just enough if you require your pods to not run under root.

Update Dockerfile

The first step is to update the Dockerfile. Two changes are required here; one to change the port and one to specify the user to use.

Exported Port

At the start of the Dockerfile, replace any EXPORT statements with the following:

ENV ASPNETCORE_URLS http://+:8000
EXPOSE 8000

This will expose your application to the cluster on port 8000 rather than port 80.

User

Next, just before the ENTRYPOINT instruction, add the following line:

USER $APP_UID

Now build and push the container to your container registry of choice either manually or via a CI/CD pipeline.

Kubernetes Manifests

Deployment Manifest

Add the following snippet to the deployment manifest under the container entry that is to be locked down:

securityContext:
  allowPrivilegeEscalation: false
  runAsNonRoot: true
  runAsUser: 1654

Service Manifest

As the exported container port has now changed, update any service you may have defined so it looks similar to the following service manifest:

apiVersion: v1
kind: Service
metadata:
  name: your-service
  namespace: service-namespace
spec:
  selector:
     app: your-app
  ports:
    - name: http
      port: 80
      targetPort: 8000
  type: ClusterIP

The pod will still be accessible via its service on port 80 so things like ingress or gateway definitions or references from other apps do not need to be updated.

Azure Kubernetes Service (AKS) and Flux – i – Introduction and AKS cluster setup

Background

I wanted an Azure Kubernetes Service (AKS) cluster to run some tests against but also, ultimately, host some sites. I wanted an easy way to manage the contents of the cluster so decided to go with a GitOps workflow using a mix of Helm and Flux. For the purposes of this walkthrough, make sure these are pre-installed along with Kubectl. You may also find a tool called K9s useful. If you’re using Windows, using Chocolatey should make installing these packages easier.

The reason behind this choice was a combination of research done both in and out of my day job.

Demo Applications

For the purposes of this article, any applications installed that aren’t generally available ones (e.g. NGINX Ingress Controller or Cert-Manager) will be my demo container and its associated Helm chart. This demo package offers multiple versions (all actually being the same image except for the version number) to test things like version ranges and, once up and running, the application can make use of other Azure services such as Azure Service Bus, Azure Key Vault and Azure App Configuration, all using managed identity.

Goal

The goal is simple – get an AKS cluster up and running, set up reserved hosting (this will save about 64% on hosting costs a month) and get a basic demo up and running.

Be aware that following these instructions will likely cost you money unless you have free Azure credit.

Walkthrough

This section is split into several subsections covering the various steps to set up an AKS cluster and some other Azure services and control access to them using a managed identity.

For the purposes of this demo, it will describe creating a cluster using the Azure Portal. Also, it’s not intended as a complete guide to using Azure so some steps will not be covered in detail.

This guide assumes you have an Azure Subscription. As I’m in the UK, all references to region will be UK South.

Azure Cluster

Go to the Azure Portal then “Kubernetes services” and select the “Create a Kubernetes cluster” option.

Choose the subscription and resource group you wish to use. For the “Cluster preset configuration” choose “Cost-optimised ($)” (this will give you a Standard_B4ms node with 4 vCPUs and 16GB of memory). You can change this but a minimum of 8GB is recommended for this demo.

Next, enter a suitable cluster name and choose the region you want. For the Kubernetes version, choose the latest available version. For the scale method, set this to “Manual” and set the number of nodes to 1.

In a normal cluster, a minimum of two nodes is recommended for resilience reasons however, for the purposes of this demo and keeping costs down, a single node will suffice.

Next navigate to node pools and choose the “userpool” node pool and change the scale method to “Manual” and the number of nodes to 0.

Access can be left on the default settings for now. On network, change the “Network configuration” to “Azure CNI” and then choose a virtual network or create a new one. For the “DNS name prefix”, use the same name as the cluster, using lowercase only, numbers and hyphens for spaces, dots, etc…

On the “Advaned” config page, for the “Infrastructure resource group”, enter the same name used for the “DNS name prefix”

Once you get to the “Review + create” tab, check everything looks OK and click “Create”. After a short while, the cluster will be provisioned and you’ll receive a notification of this.

If you wish to create a more realistic cluster but still keep costs down, I’d recommend two Standard_B2s nodes for agentpool and two Standard_B4s nodes for user pool. Remember the Kubernetes is designed to scale horizonally so, in many situations, it’s better to add nodes rather than more CPU and memory to each node. For an “early days” cluster, a series of Standard_B8ms up to Standard_B16ms would be a reasonable choice.

Reserved Pricing

Only do this if you wish to keep a machine running with the same machine SKU (i.e. spec) chosen above. It doesn’t have to be the same machine you keep but the reservation will be linked to the machine type.

Navigate to the “Purchase reservations” section of Azure and choose the “Virtual machine” option. Under recommended, you should see the virtual machine you set up above. Select it along with the term you want. Three years offers the best discounts

Azure Kubernetes Service (AKS) and Flux – ii – Flux with Empty Repository

Flux

Flux requires a hosted Git repository to function. This can be in Azure DevOps, GitHub, Bitbucket, etc…

The instructions below show how to setup an empty repository so can fully configure your cluster from scratch. There are also instructions for setting up a basic cluster featuring NGINX Ingress Controller, Cert-Manager, Seq and a demo application by copying an existing Git repo. Be aware that, out of the box, Seq is open access.

Repository Setup

This article will set up things using an empty repository. A future article (will be linked when available) will guide you through doing this with a pre-populated repo. This example uses Azure DevOps for a Git repository with a personal access token (PAT).

You’ll need to create a PAT so do this now. You’ll need “Full” code permissions for the PAT and, for ease, set expiry to be as long as possible. Longer term, other solutions will need to be used and not a PAT linked to a personal account. The default branch is called main.

Empty Repository

In Azure DevOps, create a new repository with the README enabled. For this demo, we’ll assume it’s called “Flux.Demo” Clone this repo into Visual Studio Code or another tool that’s good for editing YAML files.

You can maintain multiple clusters in one repo (AKA a monorepo) so the advice is to think about this when setting up your repository. The pattern I’ll be using is region\environment\cluster e.g. uksouth\production\01 but any logical pattern makes sense.

In VS Code, create a “clusters” folder and within it a “uksouth” folder and within that a “development” folder and within that an “01” folder. In the root, create a folder called “infrastructure” and within that create two folders: “flux-support” and “common”.

In the “flux-support” directory, create a file called “cluster-variables-configmap.yaml” and populate with the following:

apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-variables
  namespace: flux-system
data:
  cluster_region: '${cluster_region}'
  cluster_env: '${cluster_env}'
  cluster_number: '${cluster_number}'
  cluster_managedidentity_id: '${cluster_managedidentity_id}'

We’ll use this config map in other definitions when we want to know what region, environment, etc… we’re in. You can use tags on your infrastructure for some information but this will allow any value to be defined and, importantly, accessed within your Flux definitions, not just your running workloads.

In the “flux-support” folder, also create the a file called “kustomization.yaml”:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - cluster-variables-configmap.yaml

In the “common” folder, we’ll add four files which will create an NGINX Ingress Controller along with setting up Cert-Manager and allow us to deploy the demo app.

cert-manager.yaml
---
apiVersion: v1
kind: Namespace
metadata:
  name: cert-manager
  labels:
    toolkit.fluxcd.io/tenant: sre-team
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: cert-manager
  namespace: cert-manager
spec:
  interval: 24h
  url: https://charts.jetstack.io
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: cert-manager
  namespace: cert-manager
spec:
  interval: 30m
  chart:
    spec:
      chart: cert-manager
      version: "1.x"
      sourceRef:
        kind: HelmRepository
        name: cert-manager
        namespace: cert-manager
      interval: 12h
  values:
    installCRDs: true
helmrepository-jabbermouth.yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: jabbermouth
  namespace: flux-system
spec:
  interval: 5m0s
  url: oci://registry-1.docker.io/jabbermouth
  type: 'oci'
ingress-nginx.yaml
---
apiVersion: v1
kind: Namespace
metadata:
  name: ingress-nginx
  labels:
    toolkit.fluxcd.io/tenant: sre-team
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
spec:
  interval: 24h
  url: https://kubernetes.github.io/ingress-nginx
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
spec:
  interval: 30m
  chart:
    spec:
      chart: ingress-nginx
      version: '*'
      sourceRef:
        kind: HelmRepository
        name: ingress-nginx
        namespace: ingress-nginx
      interval: 12h
  values:
    controller:
      service:
        annotations:
          service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /healthz
kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - helmrepository-jabbermouth.yaml
  - cert-manager.yaml
  - ingress-nginx.yaml

Going back to the clusters\uksouth\development\01 folder, create a folder called “flux-system” and in it create two empty files called “gotk-components.yaml” and “gotk-sync.yaml”.

Back in the clusters\uksouth\development\01 folder, create a new file called “flux-support.yaml” and populate it with the following content:

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: flux-fluxsupport
  namespace: flux-system
spec:
  interval: 10m0s
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./infrastructure/flux-support
  prune: true
  wait: true
  timeout: 5m0s
  postBuild:
    substitute:
      cluster_region: 'uksouth'
      cluster_env: 'development'
      cluster_number: 'c01'
      cluster_managedidentity_id: ''

We’ll populate the cluster_managedidentity_id value later. Also note that cluster_number is prefixing the number with a “c” due to a quirk with how numbers are handled.

Next, create a file called “common.yaml” and populate it with the following content:

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: flux-common
  namespace: flux-system
spec:
  interval: 10m0s
  dependsOn:
    - name: flux-fluxsupport
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./infrastructure/common
  prune: true
  wait: true
  timeout: 5m0s

Note the dependsOn value which says this can not be run until the flux-support kustomization has completed.

First Deployment – Bootstrapping the Cluster

You show now commit your changes to Git and push them to the remote server.

The next step is to make Flux aware of the repo. The following PowerShell script will do this. Note you will need to update the three “your-” values (your-git-pat, your-organisation and your-project) with your specific values.

$REPO_TOKEN="your-git-pat"
$REPO_URL="https://dev.azure.com/your-organisation/your-project/_git/Flux.Demo"
$REGION = "uksouth",
$ENVIRONMENT = "development",
$CLUSTER = "01",
$BRANCH = "main"

flux bootstrap git `
  --token-auth=true `
  --password=$REPO_TOKEN `
  --url=$REPO_URL `
  --branch=$BRANCH `
  --path="clusters/$REGION/$ENVIRONMENT/$CLUSTER"

To view the state of your Flux deployment, you can run the following command:

flux get kustomizations

To view this in watch mode, append --watch to the end of the command. Once all the rows show as ready and have an “Applied revision: main@sha1:…” then your cluster should be ready.

Next, run the following command to get the external IP of your ingress controller:

kubectl get svc -n ingress-nginx

Note the external IP for later use. You can also do a simple test by going to http:/// and you should see a “404 Not Found” error page from Nginx.

Demo Application

In the cluster definition folder (clusters\uksouth\development\01), create a file called “applications.yaml” and populate it with the following contents:

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: flux-applications
  namespace: flux-system
spec:
  interval: 10m0s
  dependsOn:
    - name: flux-common
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./applications/demo
  prune: true
  wait: true
  timeout: 5m0s
  postBuild:
    substituteFrom:
      - kind: ConfigMap
        name: cluster-variables

Now, in the root, create a folder called “applications” and, in that folder, create a folder called “demo”. We’ll create two files in that folder:

demo.yaml

---
apiVersion: v1
kind: Namespace
metadata:
  name: applications
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: demoapp
  namespace: applications
spec:
  interval: 5m
  chart:
    spec:
      chart: helm-demo
      version: '^1.0.0'
      sourceRef:
        kind: HelmRepository
        name: jabbermouth
        namespace: flux-system
      interval: 1m
  values:
    ingress:
      domain: demo.yourdomain.com
      path: /simple
      tls:
        enabled: true
        letsEncryptEmail: 'youremail@yourdomain.com'
    config:
      environment:
        overridden:
          createAs: 'inline'
          value: 'Common to all environments'
        onlyFromEnvVar:
          value: 'Demo application'

You will need to update the ingress section with your domain and email address.

Next, create the kustomization file in the applications\demo folder:

#### kustomization.yaml

```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - demo.yaml

Before pushing your changes, create a DNS record that matches the domain entered above, using the external IP address you retrieved in the previous section.

Once the DNS record is configured, commit and push your changes to repo and monitor Flux as before:

flux get kustomizations To force a reconciliation, you can run the following command:

flux reconcile kustomization flux-system --with-source

If all worked as expected, going to the equivalent of https://demo.yourdomain.com/simple should show a simple demo site with a valid “SSL” certificate.

PowerShell TDD with Pester – Adding More Rules (part 2 of 3)

In this article, we’ll add more rules to our version number generator to handle things like bug fixes to major versions, pre-release labels and build labels.

To help guide the rules being implemented, below is a table of the incrementation rules that will be applied.

Element Incrementation Notes
Major Version in file used with major versions treated separately for incrementation purposes
Minor Latest value used within major version.
Patch One higher than previous version unless major or minor version has increased in which case should be 0
Pre-Release Label Removing it or leaving one in place won’t cause an increase (if no existing version exists on that level) but adding it will increase the patch version the label changing doesn’t matter
Build Label Does not cause any incrementations.

Seventh Test – Minor Version Increase Resets Patch

This test is about minor version number changes causing the patch to reset to 0 when the minor version number changes but otherwise it should be increased.

Failing Test

It 'Minor version change in file version (<fileVersion>) compared to published version (<publishedVersion>) causes patch to reset to 0 (<expectedVersion>)' -ForEach @(
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0"; ExpectedVersion = "1.0.1" }
  @{ FileVersion = "1.1.0"; PublishedVersion = "1.0.0"; ExpectedVersion = "1.1.0" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.1.0"; ExpectedVersion = "1.1.1" }
  @{ FileVersion = "1.1.0"; PublishedVersion = "1.1.0"; ExpectedVersion = "1.1.1" }
) {
  # Arrange
  $fileVersion = [SemanticVersion]$fileVersion
  $publishedVersion = [SemanticVersion]$nugetVersion
  $preReleaseLabel = ""
  $buildLabel = ""

  $expectedVersion = [SemanticVersion]$expectedVersion

  # Act
  $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

  # Assert
  $newVersion | Should -Be $expectedVersion
}

Making the Test Green

To make this test work, a few small changes are needed. A resetPatch boolean variable is needed, defaulting to false and the published version check needs splitting and a new method adding. Finally, the new patch version calculator needs some extra logic to handle the resetPatch variable.

Once this is all put together, it looks like this:

function Get-NextVersion {
  param (
    [SemanticVersion] $FileVersion,
    [SemanticVersion] $PublishedVersion
  )

  $newVersion = [SemanticVersion]"0.0.0"
  $notSet = [SemanticVersion]"0.0.0"
  $resetPatch = $false

  if ($FileVersion -ne $notSet -and $FileVersion -gt $newVersion) {
    $newVersion = $FileVersion
  }
  if ($PublishedVersion -ne $notSet) {
    if ($newVersion.Minor -gt $PublishedVersion.Minor) {
      $resetPatch = $true
    }
    if ($PublishedVersion -gt $newVersion) {
      $newVersion = $PublishedVersion
    }    
  }

  $patch = $newVersion.Patch + 1;
  if ($resetPatch) {
    $patch = 0
  }
  $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $newVersion -PatchVersion $patch)

  Write-Output $newVersion
}

This change will, however, cause our previous test to fail. To resolve this, update the second data set’s expected value from 1.1.1 to 1.1.0.

Eighth Test – Pre-release Label Increments Patch If Added But Not If Removed Or Already Present

This test builds the logic for handling the use of pre-release tags (e.g. “-dev” or “-test”). It should only increase the patch version if adding a label. The value of the label isn’t important.

Failing Test

It 'Pre-release tag increments patch if added (<preReleaseTag>) but not if removed or already present (<publishedVersion> --> <expectedVersion>)' -ForEach @(
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0"; PreReleaseLabel = "dev"; ExpectedVersion = "1.0.1-dev" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev"; PreReleaseLabel = "dev"; ExpectedVersion = "1.0.0-dev" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev"; PreReleaseLabel = ""; ExpectedVersion = "1.0.0" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev"; PreReleaseLabel = "test"; ExpectedVersion = "1.0.0-test" }
  @{ FileVersion = "1.1.0"; PublishedVersion = "1.0.1-dev"; PreReleaseLabel = "dev"; ExpectedVersion = "1.1.0-dev" }
  @{ FileVersion = "1.1.0"; PublishedVersion = "1.0.1-dev"; PreReleaseLabel = ""; ExpectedVersion = "1.1.0" }
) {
  # Arrange
  $fileVersion = [SemanticVersion]$fileVersion
  $publishedVersion = [SemanticVersion]$publishedVersion
  $buildLabel = ""

  $expectedVersion = [SemanticVersion]$expectedVersion

  # Act
  $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

  # Assert
  $newVersion | Should -Be $expectedVersion
}

Making the Test Green

The main code changes required are to do with determining if the patch version should be incremented or not. This is largely centred around whether or not the pre-release label is on the existing published package or not.

function Get-NextVersion {
  param (
    [SemanticVersion] $FileVersion,
    [SemanticVersion] $PublishedVersion,
    [string] $PreReleaseLabel
  )

  $newVersion = [SemanticVersion]"0.0.0"
  $notSet = [SemanticVersion]"0.0.0"
  $incrementPatch = $true
  $resetPatch = $false

  if ($FileVersion -ne $notSet -and $FileVersion -gt $newVersion) {
    $newVersion = $FileVersion
  }
  if ($PublishedVersion -ne $notSet) {
    if (![string]::IsNullOrWhiteSpace($PublishedVersion.PreReleaseLabel)) {
      $incrementPatch = $false
    }
    if ($newVersion.Minor -gt $PublishedVersion.Minor) {
      $resetPatch = $true
    }
    if ($PublishedVersion -gt $newVersion) {
      $newVersion = $PublishedVersion
    }    
  }

  $patch = $newVersion.Patch
  if ($resetPatch) {
    $patch = 0
  } elseif ($incrementPatch) {
    $patch++
  }
  $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $newVersion -PatchVersion $patch -PreReleaseLabel $PreReleaseLabel)

  Write-Output $newVersion
}

Ninth Test – Any Build Label Stops Patch Incrementation

If a build label is specified, it will stop patch incrementation from happening. It won’t stop resets though.

Failing Test

It 'Any build label (<buildLabel>) stops patch incrementation (<publishedVersion> --> <expectedVersion>)' -ForEach @(
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev"; PreReleaseLabel = "dev"; BuildLabel = "1234"; ExpectedVersion = "1.0.0-dev+1234" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev+1234"; PreReleaseLabel = "dev"; BuildLabel = "1234"; ExpectedVersion = "1.0.0-dev+1234" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0-dev+1233"; PreReleaseLabel = "dev"; BuildLabel = "1234"; ExpectedVersion = "1.0.0-dev+1234" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0"; PreReleaseLabel = ""; BuildLabel = "1234"; ExpectedVersion = "1.0.0+1234" }
  @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.0"; PreReleaseLabel = "dev"; BuildLabel = "1234"; ExpectedVersion = "1.0.1-dev+1234" }
) {
  # Arrange
  $fileVersion = [SemanticVersion]$fileVersion
  $publishedVersion = [SemanticVersion]$publishedVersion

  $expectedVersion = [SemanticVersion]$expectedVersion

  # Act
  $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

  # Assert
  $newVersion | Should -Be $expectedVersion
}

Making the Test Green

To make this test, the build number parameter needs adding and an “else if” condition adding after the pre-release label check to stop incrementing if a new tag isn’t present but a build number is.

function Get-NextVersion {
  param (
    [SemanticVersion] $FileVersion,
    [SemanticVersion] $PublishedVersion,
    [string] $PreReleaseLabel,
    [string] $BuildLabel
  )

  $newVersion = [SemanticVersion]"0.0.0"
  $notSet = [SemanticVersion]"0.0.0"
  $incrementPatch = $true
  $resetPatch = $false

  if ($FileVersion -ne $notSet -and $FileVersion -gt $newVersion) {
    $newVersion = $FileVersion
  }

  if ($PublishedVersion -ne $notSet) {
    if (![string]::IsNullOrWhiteSpace($PublishedVersion.PreReleaseLabel)) {
      $incrementPatch = $false
    } elseif ([string]::IsNullOrWhiteSpace($PreReleaseLabel) -and ![string]::IsNullOrWhiteSpace($BuildLabel)) {
      $incrementPatch = $false
    }
    if ($newVersion.Minor -gt $PublishedVersion.Minor) {
      $resetPatch = $true
    }
    if ($PublishedVersion -gt $newVersion) {
      $newVersion = $PublishedVersion
    }    
  }

  $patch = $newVersion.Patch
  if ($resetPatch) {
    $patch = 0
  } elseif ($incrementPatch) {
    $patch++
  }
  $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $newVersion -PatchVersion $patch -PreReleaseLabel $PreReleaseLabel -BuildLabel $BuildLabel)

  Write-Output $newVersion
}

Tenth Test – Reading Files

The next test, covered in the next blog, will cover testing the process of reading files.

PowerShell TDD with Pester – Adding More Rules (part 3 of 3)

PowerShell TDD with Pester – Adding More Rules (part 3 of 3)

This final article will cover reading mocked files and retrieving the version number from the CSProj or Chart.yaml file.

Tenth Test – Reading From Chart.yaml File

The first test is to read a Helm’s Chart.yaml file, extract the current current chart version and call the Get-NextVersion function.

Failing Test

Describe 'Read-CurrentVersionNumber' {
  It 'Extract current chart version from Chart.yaml and call Get-NextVersion' {
    # Arrange
    $expected = [SemanticVersion]"1.0.0"

    # Act
    $actual = [SemanticVersion](Read-CurrentVersionNumber -File "Chart.yaml)

    # Assert
    $actual | Should -Be $expected
  }
}

To give a fail (rather than non-running) test, add the following function:

function Read-CurrentVersionNumber {

}

Making the Test Green

As we’re reading a YAML file, we’ll use the powershell-yaml module.

To make the test pass, simple return “1.0.0” from the function:

function Read-CurrentVersionNumber {
  Write-Output "1.0.0"
}

Refactor to Read (Faked) File

To make the code (simulate) reading a file, we need to add a parameter to pass in the file name, process the YAML and return the version.

function Read-CurrentVersionNumber {
  param (
    [Parameter(Mandatory=$true)] [string] $file
  )

  Import-Module powershell-yaml

  $chartYaml = (Get-Content -Path $file | ConvertFrom-Yaml)

  Write-Output $chartYaml.version
}

We’ll also add some code to BeforeAll to mock certain functions to prevent module installs happening as part of unit tests.

  function Get-Content {
    Write-Output "
    apiVersion: v2
    appVersion: 1.0.0
    description: A Helm chart to deploy applications to a Kubernetes cluster using standard settings
    name: Generalised
    type: application
    version: 1.0.0    
    "

Eleventh Test – Reading From CSProj File

This will cover reading from the XML-formatted CSProj file.

  It 'Extract current chart version from a CSProj file' {
    # Arrange
    $expected = [SemanticVersion]"1.2.0"

    # Act
    $actual = [SemanticVersion](Read-CurrentVersionNumber -File "My.Project.csproj")

    # Assert
    $actual | Should -Be $expected
  }

Making the Test Green

We’ll put some logic around the code reading the version so that it handles YAML files vs XML files. We’ll also need to add another mock to handle the reading of the CSProj/XML file.

The code changes involve a new if statement that analyses the file name being passed:

function Read-CurrentVersionNumber {
  param (
    [Parameter(Mandatory=$true)] [string] $file
  )

  $fileName = Split-Path $file -Leaf

  if ($fileName -eq "Chart.yaml") {
    Import-Module powershell-yaml

    $chartYaml = (Get-Content -Path $file | ConvertFrom-Yaml)

    Write-Output $chartYaml.version
  } elseif ($fileName.EndsWith(".csproj")) {
    $projectVersion = $(Select-Xml -Path "$file" -XPath '/Project/PropertyGroup/Version').Node.InnerText

    Write-Output $projectVersion
  }
}

To work, this needs to following stub adding:

  function Select-Xml {
    Write-Output @{
      Node = @{
        InnerText = "1.2.0"
      }
    }
  }

PowerShell TDD with Pester – Setup and Basic Tests (part 1 of 3)

Following on from my experiment with TDD using C#, I wanted to look at TDD with PowerShell as that’s another language I currently use quite a bit. To do this, I will be using the Pester framework. These articles are covering how I built a function to generate a new version number for a NuGet package, Helm chart, etc… based upon the version number in a file (e.g. chart.yaml or csproj) and the latest published version.

In these articles you may see more casting (e.g. [SemanticVersion]) than is necessary however I find it’s clearer to include it when dealing with non-basic types likes strings and ints.

As with my C# TDD articles, this will also have a GitHub repository made available. When it is, the link to it will appear here.

These articles are evolving so, particularly the third part, will change over time.

Pester Installing or Updating

I’m using Windows 11, PowerShell Core 7 and VS Code for this project so any instructions will be for that platform.

To install the latest version of Pester and ensure any existing versions don’t cause issues, including the default v3 installed with Windows, run the following command:

Install-Module -Name Pester -Force
Import-Module Pester

The second command is to force the current session to pick up the latest version of Pester.

First Test – Return 0.0.1 If No Version Exists

The first test should return 0.0.1 if no existing version is found and the csproj file version is 0.0.1 or less, no pre-release tag is specified and no build number is specified.

Failing Test

using namespace System.Management.Automation

BeforeAll {
  . $PSScriptRoot/Get-NextVersion.ps1
}

Describe 'Get-NextVersion' {
  It 'Return 0.0.1 when no meaningful version exists' {
    # Arrange
    $fileVersion = [SemanticVersion]"0.0.0"
    $publishedVersion = [SemanticVersion]"0.0.0"
    $preReleaseLabel = ""
    $buildLabel = ""

    $expectedVersion = [SemanticVersion]"0.0.1"

    # Act
    $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

    # Assert
    $newVersion | Should -Be $expectedVersion
  }
}

To make this test run, an empty function definition should be added to the main ps1 file:

function Get-NextVersion {

}

The tests can then be ran using the following command:

Invoke-Pester -Output Detailed .\Get-NextVersion.Tests.ps1

Making the Test Green

To make the test pass, the easiest thing to do is simply return the expected version of 0.0.1 so that is what we’ll do.

function Get-NextVersion {
  Write-Output "0.0.1"
}

Second Test – Returning One Patch Higher Than The Latest Published Package

This test will ensure that any version number has a one higher patch number than the latest published patch. At this stage, the pre-release tag and build number are being ignored.

Failing Test

It 'Return one patch higher than existing published version' {
  # Arrange
  $fileVersion = [SemanticVersion]"0.0.0"
  $nugetVersion = [SemanticVersion]"0.3.1"
  $preReleaseLabel = ""
  $buildLabel = ""

  $expectedVersion = [SemanticVersion]"0.3.2"

  # Act
  $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

  # Assert
  $newVersion | Should -Be $expectedVersion
}

Making the Test Green

To make the test green, we need to add a parameter to the Get-NextVersion function and then use it to add suitable logic. We should only add the needed parameter from the ones currently being passed.

The resulting function code will look like this:

using namespace System.Management.Automation

function Get-NextVersion {
  param (
    [SemanticVersion] $PublishedVersion
  )

  $newVersion = [SemanticVersion]"0.0.1"
  $notSet = [SemanticVersion]"0.0.0"

  if ($NuGetVersion -ne $notSet) {
    $newVersion = [SemanticVersion]::new($PublishedVersion.Major, $PublishedVersion.Minor, $PublishedVersion.Patch + 1, $PublishedVersion.PreReleaseLabel, $PublishedVersion.BuildLabel)
  }

  Write-Output $newVersion
}

This will work but could mean a lot of repeated and similar code each time one or more parts of the version change. Therefore, it would make sense to create a function for building up a new version, only updating those values which have changed.

Third Test – Function To Build New SemanticVersion When All Parameters Are Set

This test will pass in all new values for a SemanticVersion object and return a new version. This is effectively replicating the constructor but test four will handle no parameters being passed making this new function useful as only the changed values will need to be passed.

Failing Test

Describe 'Get-UpdatedSemanticVersion' {
  It 'Returns completely new version when all parameters are set' {
    # Arrange
    $currentVersion = [SemanticVersion]"1.2.3-dev+build1"
    $expectedVersion = [SemanticVersion]"4.5.6-new+build2"

    # Act
    $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $currentVersion -MajorVersion 4 -MinorVersion 5 -PatchVersion 6 -PreReleaseLabel "new" -BuildLabel "build2")

    # Assert
    $newVersion | Should -Be $expectedVersion
  }
}

Also, so the test fails (rather than fails to execute), add the following method to Get-NextVersion.ps1:

function Get-UpdatedSemanticVersion {
}

Making the Test Green

Making the test pass when all the parameters are specified is relatively straight forward:

function Get-UpdatedSemanticVersion {
  param (
    [Parameter(Mandatory, Position=0)] [SemanticVersion] $CurrentVersion,
    [int] $MajorVersion,
    [int] $MinorVersion,
    [int] $PatchVersion,
    [string] $PreReleaseLabel,
    [string] $BuildLabel
  )

  $newVersion = [SemanticVersion]::new($MajorVersion, $MinorVersion, $PatchVersion, $PreReleaseLabel, $BuildLabel)

  Write-Output $newVersion
}

But to make this test useful, only updating the new values passed is key so a new test is needed.

Fourth Test – Return Current Version When No Parameters Passed

Failing Test

  It 'Returns current version when all parameters except CurrentVersion are not set' {
    # Arrange
    $currentVersion = [SemanticVersion]"1.2.3-dev+build1"
    $expectedVersion = [SemanticVersion]"1.2.3-dev+build1"

    # Act
    $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $currentVersion)

    # Assert
    $newVersion | Should -Be $expectedVersion
  }

Making the Test Green

We need to check if all parameters were passed or not. We can use $PSBoundParameters to do this by checking each parameter name.

function Get-UpdatedSemanticVersion {
  param (
    [Parameter(Mandatory, Position=0)] [SemanticVersion] $CurrentVersion,
    [int] $MajorVersion,
    [int] $MinorVersion,
    [int] $PatchVersion,
    [string] $PreReleaseLabel,
    [string] $BuildLabel
  )

  if (($PSBoundParameters.ContainsKey('MajorVersion') -eq $false) -and
      ($PSBoundParameters.ContainsKey('MinorVersion') -eq $false) -and 
      ($PSBoundParameters.ContainsKey('PatchVersion') -eq $false) -and 
      ($PSBoundParameters.ContainsKey('PreReleaseLabel') -eq $false) -and
      ($PSBoundParameters.ContainsKey('BuildLabel') -eq $false)) {
    $newVersion = $CurrentVersion
  } else {
    $newVersion = [SemanticVersion]::new($MajorVersion, $MinorVersion, $PatchVersion, $PreReleaseLabel, $BuildLabel)
  }

  Write-Output $newVersion
}

Fifth Test – Removing Labels

This fifth test will be testing two things in a way – removing the labels when they’re already defined and that only some parameters can be specified for updating.

Failing Test

  It 'Returns current version with labels when labels are set to blanks strings' {
    # Arrange
    $currentVersion = [SemanticVersion]"1.2.3-dev+build1"
    $expectedVersion = [SemanticVersion]"1.2.3"

    # Act
    $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $currentVersion -PreReleaseLabel "" -BuildLabel "")

    # Assert
    $newVersion | Should -Be $expectedVersion
  }

Making the Test Green

We’ll do a check per parameter and set the new value to use to the current version if a new one isn’t specified.

function Get-UpdatedSemanticVersion {
  param (
    [Parameter(Mandatory, Position=0)] [SemanticVersion] $CurrentVersion,
    [int] $MajorVersion,
    [int] $MinorVersion,
    [int] $PatchVersion,
    [string] $PreReleaseLabel,
    [string] $BuildLabel
  )

  if ($PSBoundParameters.ContainsKey('MajorVersion') -eq $false) {
    $MajorVersion = $CurrentVersion.Major
  }

  if ($PSBoundParameters.ContainsKey('MinorVersion') -eq $false) {
    $MinorVersion = $CurrentVersion.Minor
  }

  if ($PSBoundParameters.ContainsKey('PatchVersion') -eq $false) {
    $PatchVersion = $CurrentVersion.Patch
  }

  if ($PSBoundParameters.ContainsKey('PreReleaseLabel') -eq $false) {
    $PreReleaseLabel = $CurrentVersion.PreReleaseLabel
  }

  if ($PSBoundParameters.ContainsKey('BuildLabel') -eq $false) {
    $BuildLabel = $CurrentVersion.BuildLabel
  }

  $newVersion = [SemanticVersion]::new($MajorVersion, $MinorVersion, $PatchVersion, $PreReleaseLabel, $BuildLabel)

  Write-Output $newVersion
}

Refactoring Get-NextVersion to Use This New Method

The code change is pretty simple and then, once made, re-run the tests to make sure all responses are working as expected.

Change the following line:

$newVersion = [SemanticVersion]::new($PublishedVersion.Major, $PublishedVersion.Minor, $PublishedVersion.Patch + 1, $PublishedVersion.PreReleaseLabel, $PublishedVersion.BuildLabel)

To the following:

$newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $PublishedVersion -PatchVersion ($PublishedVersion.Patch + 1))

Sixth Test – New Version Is One Version Higher Than the Higher of File Version and NuGet Version

This test is about ensuring the next version is higher than any existing version. Three data sets will be tried; one where file version is higher than published version, one where it’s lower and one where it’s the same.

Failing Test

  It 'New version is one version higher (<expectedVersion>) than the higher of file version (<fileVersion>) and published version (<publishedVersion>)' -ForEach @(
    @{ FileVersion = "1.0.0"; PublishedVersion = "1.0.3"; ExpectedVersion = "1.0.4" }
    @{ FileVersion = "1.1.0"; PublishedVersion = "1.0.3"; ExpectedVersion = "1.1.1" }
    @{ FileVersion = "1.2.0"; PublishedVersion = "1.2.0"; ExpectedVersion = "1.2.1" }
  ) {
    # Arrange
    $fileVersion = [SemanticVersion]$fileVersion
    $publishedVersion = [SemanticVersion]$publishedVersion
    $preReleaseLabel = ""
    $buildLabel = ""

    $expectedVersion = [SemanticVersion]$expectedVersion

    # Act
    $newVersion = [SemanticVersion](Get-NextVersion -FileVersion $fileVersion -PublishedVersion $publishedVersion -PreReleaseLabel $preReleaseLabel -BuildLabel $buildLabel)

    # Assert
    $newVersion | Should -Be $expectedVersion
  }

You’ll notice that there are now 8 tests passing/failing even though we’ve only written six. That is because the -ForEach is effectively creating multiple variants of one test.

Making the Test Green

We’ll need to add a new parameter (FileVersion) and use this to determine what the highest new version is. By doing independent checks and updating newVersion as appropriate, the code is easier to read. newVersion is now defaulting to 0.0.0 so that the patch can also be incremented. We’ll see in later tests that we may not always want this.

function Get-NextVersion {
  param (
    [SemanticVersion] $FileVersion,
    [SemanticVersion] $PublishedVersion
  )

  $newVersion = [SemanticVersion]"0.0.0"
  $notSet = [SemanticVersion]"0.0.0"

  if ($FileVersion -ne $notSet -and $FileVersion -gt $newVersion) {
    $newVersion = $FileVersion
  }
  if ($PublishedVersion -ne $notSet -and $PublishedVersion -gt $newVersion) {
    $newVersion = $PublishedVersion
  }

  $newVersion = [SemanticVersion](Get-UpdatedSemanticVersion $newVersion -PatchVersion ($newVersion.Patch + 1))

  Write-Output $newVersion
}

Seventh Test

As this article is getting quite long, I’ll stop here and continue in the next article which will see more rules added and some mocking done.

Test Driven Development (TDD) – C# – Blazor Server with bUnit (part 4 of 5)

In this third article on building an app using TDD, we’ll build a blog list component and add it to the default page. We’ll be using bUnit for the testing of this component.

Fourth Test – Blog List UI When Empty

The first test we’ll write will cover a blog list when no values are available. We’ll need to mock the BlogPost class created in the previous articles and we’ll do this by creating an interface of the class.

The test will look like this:

[Fact]
public void ShowNoBlogsFoundOnEmptyList()
{
    // Arrange
    IEnumerable<BlogPostEntry> blogPostEntries = new List<BlogPostEntry>();
    Mock<IBlogPost> blogPost = new();
    blogPost.Setup(method => method.ListAsync()).Returns(Task.FromResult(blogPostEntries));

    Services.AddSingleton<IBlogPost>(blogPost.Object);
    var cut = RenderComponent<BlogList>();

    // Act

    // Assert
    cut.Find("p").MarkupMatches("<p>There are no blogs to display.</p>");
}

This will require bUnit and Microsoft.Extensions.DependencyInjection installing. You will also need to create a project reference to Demo.Tdd.Contentful. The class should inherit from TestContext.

The next step is to create the interface for BlogPost and inherit from it. In Visual Studio, you can do this by going into the class, right clicking on the class name, choosing “Quick Actions and Refactorings…” and choosing the “Extract Interface” option. The default values should be sufficient. Click OK and the interface will be created and the current class set to inherit from it.

In the UI project, create a “Shared” folder and within it create a new Razor component called “BlogList.razor”.

Making the Test Green

Populate the BlogList with the following:

<p>There are no blogs to display.</p>

@code {

}

This will satisfy the test but is obviously never going to return anything dynamic and isn’t using the mocked object we’ve created in the test, etc… As done previously, we’ll create a new test that handles the scenario where data exists and make use of the mock we’ve created.

Fifth Test – Listing Blogs

This test will require a few changes to make it pass and will require a couple of properties adding to BlogPostEntry just to make the code compile. This test should also spawn other tests such as one that says the ListAsync method returns title, summary and article when called.

Failing Test

The new test should look like:

[Fact]
public void ListMostRecentBlogs()
{
    // Arrange
    IEnumerable<BlogPostEntry> blogPostEntries = new List<BlogPostEntry>() { new BlogPostEntry() { Title = "Test blog article", Summary = "A bit of blurb with some __bold__ text." } };
    Mock<IBlogPost> blogPost = new();
    blogPost.Setup(method => method.ListAsync()).Returns(Task.FromResult(blogPostEntries));

    Services.AddSingleton<IBlogPost>(blogPost.Object);
    var cut = RenderComponent<BlogList>();

    // Act
    cut.WaitForState(() => cut.Find("ul") is not null);

    // Assert
    var ul = cut.Find("ul");
    ul.Children[0].MarkupMatches("<li><b>Test blog article</b><p>A bit of blurb with some <strong>bold</strong> text.</p></li>");
}

To make the code compile, add the following code to the BlogPostEntry class in Demo.Tdd.Contentful:

public string? Title { get; set; }
public string? Summary { get; set; }

Making the Test Green

To make both tests pass, the BlogPost component needs to be updated to accept the injected IBlogPost and iterate through any results. As we are storing Markdown in Contentful, we’ll need to use a package to convert this to HTML. The package I recommend is Markdig. Also, to make Blazor render the HTML as HTML and not a string, we’ll cast the value to MarkupString.

Update the BlogList component as follows:

@using Demo.Tdd.Contentful;
@using Demo.Tdd.Contentful.Models;
@using Markdig;

@inject IBlogPost _blogPost;

@if (blogList is null || !blogList.Any())
{
    <p>There are no blogs to display.</p>
}
else
{
    <ul>
    @foreach (var blogEntry in blogList)
    {
        <li>
            <b>@blogEntry.Title</b>
            @((MarkupString)Markdown.ToHtml(blogEntry.Summary ?? ""))
        </li>
    }
    </ul>
}

@code {
    IEnumerable<BlogPostEntry>? blogList = null;

    protected override async Task OnInitializedAsync()
    {
        blogList = await _blogPost.ListAsync();
    }
}

The test will now pass but the code will fail to run.

Sixth Test – Add BlogList to Index Page

The final UI test for this series of blogs is to add the component to the Index.razor page. We’ll write a test for this to confirm the component is present.

Failing Test

Firstly, create a new class within the UI testing project called HomePageShould, make the class public and inherit from TestContext. Then populate it with the following test:

[Fact]
public void IncludeBlogListComponent()
{
    // Arrange
    Mock<IBlogPost> blogPost = new();
    Services.AddSingleton<IBlogPost>(blogPost.Object);
    var put = RenderComponent<Pages.Index>();

    // Act

    // Assert
    Assert.True(put.HasComponent<BlogList>());
}

No additional changes are required to make the code compile and test fail.

Making the Test Green

Firstly, in the _Imports.razor page, add the following line:

@using Demo.Tdd.Ui.Shared

This removes the need to add this to each page using a shared component.

The add the following to Index.razor:

<BlogList></BlogList>

This will result in a passing test. The code won’t run but we’ll cover that in the refactor stage.

Refactor to Make Code Run

To allow the code to run, in Program.cs, under the other service declarations, add the following:

builder.Services.AddHttpClient();
builder.Services.AddSingleton<IBlogPost, BlogPost>();

Be aware that this will only work if no errors occur (i.e. the API key is correct and the space and environment values are correct. Also, as no code has been added to BlogPost.cs to return the title and summary, if you’ve added a blog entry, you won’t see the title or summary.

Seventh Test – Handle an Error from the API

The final tests we’ll write are to return an empty list if an error occurs and to return values. This will be covered in the next blog article.