Skip to content

Tutorials

Istio and JWT authentication

I’ve been looking at a workflow which allow the JWT authentication in Istio to be used for both UI and API requests. The basic idea is to make use of Istio’s JWT authentication mechanism to control initial access and then use an authorization policy to control access to specific services.

The diagram below shows the basic workflow of how this will work. It has been simplified by removing things like refresh tokens or authorization policies.

Flow chart showing JWT flow

The basic idea is that once the basic auth is done to a 3rd party service such as Azure AD/Entra ID or Google Workspace, a signed token is generated and stored in a cookie. This cookie value is then used for further auth requests. For APIs, the cookie value can be used directly and passed to any API calls. For UIs, because Istio can’t currently extract tokens from, an Envoy Filter is used. This looks for a specific cookie and copies the value to the authentication header before forwarding the request to Istio’s authentication handler.

If the token is valid (signed by a known private key and hasn’t expired), traffic is allowed to pass (subject to authorization policies) or rejected. On unauthorised requests, APIs just respond as unauthorised where as the UI redirects to the /auth page.

Creating a simple Kubernetes operator in .NET (C#)

Note: I'd recommend building a Kubernetes operator using Go as it is the language of choice for Kubernetes. This article is more of a proof of concept.

Creating a Kubernetes operator can seem a bit overwhelming at first. To help, there’s a simple NuGet package called CanSupportMe.Operator which can be as simple as watching for a secret or config map or creating a custom resource definition (CRD) and watching that. You then get call backs for new items, modified items, reconciling items, deleting items and deleted items. (* CRDs only)

The call backs also expose a data object manager which lets you do things like create secrets and config maps, force the reconciliation of an object, clear all finalizers and check if a resource exists.

Example

The follow is a console application with the following packages installed:

dotnet add package Microsoft.Extensions.Configuration.Abstractions
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.Abstractions
dotnet add package CanSupportMe.Operator

If you’re not use a context with a token in your KUBECONFIG, use the commented out failover token lines to specify one (how to generate one is on the NuGet package page).

using CanSupportMe.Operator.Extensions;
using CanSupportMe.Operator.Models;
using CanSupportMe.Operator.Options;
using Microsoft.Extensions.Hosting;

// const string FAILOVER_TOKEN = "<YOUR_TOKEN_GOES_HERE>";

try
{  
  Console.WriteLine("Application starting up");

  IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
      services.AddOperator(options =>
      {
        options.Group = "";
        options.Kind = "Secret";
        options.Version = "v1";
        options.Plural = "secrets";
        options.Scope = ResourceScope.Namespaced;
        options.LabelFilters.Add("app.kubernetes.io/managed-by", "DemoOperator");

        options.OnAdded = (kind, name, @namespace, item, dataObjectManager) =>
        {
          var typedItem = (KubernetesSecret)item;

          Console.WriteLine($"On {kind} Add: {name} to {@namespace} which is of type {typedItem.Type} with {typedItem.Data?.Count} item(s)");
        };

        options.FailoverToken = FAILOVER_TOKEN;
      });
    })
    .Build();

  host.Run();
}
catch (Exception ex)
{
  Console.WriteLine($"Application start failed because {ex.Message}");
}

To create a secret that will trigger the OnAdded call back, create a file called secret.yaml with the following contents:

apiVersion: v1
kind: Secret
metadata:
  name: test-secret-with-label
  namespace: default
  labels:
    app.kubernetes.io/managed-by: DemoOperator
stringData:
  notImportant: SomeValue
type: Opaque

Then apply it to your Kubernetes cluster using the following command:

kubectl apply -f secret.yaml

This should result in the following being output to the console:

On Secret Add: test-secret-with-label to default which is of type Opaque with 1 item(s)

My deployment to Azure using Terraform and Flux

To deploy my needed infrastructure and applications to Azure, I use a combination of Terraform and Flux, all running from GitHub Actions workflows. This is only a high level overview and some details have been excluded or skimmed over for brevity.

Terraform

For me, one of the biggest limitations of Terraform is how bad it is at DRY (i.e. Don’t Repeat Yourself). I wanted to maintain a pure Terraform solution whilst trying to minimise the repetition but also allow the easy spinning up of new clusters as needed. I also knew I needed a “global” environment as well as various deployment environments but, for now, development and production will suffice.

Modules

Each Terraform modules is in it’s own repo with a gitinfo.txt file specifying the version of the module. On merge to main, a pipeline runs which tags the commit with Major, Major.Minor and Major.Minor.Patch tags so that the modules can be specifically pinned or, if desired, a broader level of pinning can be used.

Folder Structure

Each module contains a src folder which contains an examples folder for Terraform examples of the modules being used and a module folder which contains the actual module. This will be then referenced by the scope repos.

Scopes

Three scope repos are in use – Environment, Global and GitHub – which make use of the above modules and standard Terraform resources.

Global and GitHub are single use scopes and are only utilised once and configure all global resources (e.g. DNS zones) and all GitHub repositories. The latter holds configuration for all repositories in GitHub including it’s own. The initial application of this, to get round the chicken and egg problem, will be covered in another article.

The environment scope is used to configure all environments and within each environment scope, there are regions and within them are farms and within them are clusters. This allows for common resources to be placed at the appropriate level.

Folder Structure

Each scope contains a src folder which contains three sub folders:

  • collection – This contains the resources to be applied to the scope
  • config – This contains one or more tfvars files which, especially in the case of environments, contain the different configuration to be applied to each environment
  • scope – This is the starting point of a scope and it what is referenced by the plan and apply pipelines – it should only contain a single reference to the module in ../collection

Data Between Scopes

To pass data between scopes, the pipelines create secrets within GitHub which can then be referenced by other pipelines and passed to the target scope. The main example of this is the global scope needs to pass data, e.g. DNS details, to the environments.

Variables in Environment Scope

To aid with data passing between layers within the environment, a few variables have been set up which get passed through the layers and can have data added as they pass down the chain. These variables are defaults, created_infrastructure and credentials. As an example, when an environment level key vault is created, it’s ID and name are added to created_infrastructure so that access can be set up for the AKS cluster using a managed identity.

Flux

Bootstrapping

When a cluster is provisioned, it is automatically given a cluster folder in the Flux repo and has a cluster_variables secret created to store values that either Flux or Helm charts being applied by Flux may need including things like the region or global key vault’s name.

Folder Structure

The top level folder is the clusters folder and it contains a _template folder along with a folder per environment. The individual environment folders then contain a folder for the region, the farm, and finally the cluster (e.g. development/uksouth/f01/c01). The c01 folder is based on the _template folder.

The remaining folders are applications, clients, infrastructure, redirects and services with each of these being referenced from the c01 folder.

The infrastructure folder contains setup manifests and Helm releases for things like Istio, External DNS or External Secrets Operator.

The redirects folder is split up by environment and defines any redirects which should be added for that environment. These are managed using a redirects Helm chart and a series of values passed to its HelmRelease object.

The services folder allows you to define services which should be deployed at the various levels of global, environmental, regional, farm or cluster level. There is a definitions folder containing the base definition for each service.

The applications folder defines applications which should be deployed to specific clusters and, as with services, there is a definitions folder which contains the default configuration. These are generally non-targeted applications such as a commercial landing page.

The final folder is clients which contains a definition for any client applications. It’s quite likely this folder may only contain a single definition if only a single SaaS application (note this is single application, not single microservice) exists. There are then the usual nested environment, region, farm and cluster folders with each cluster defining the clients that are deployed to that specific instance.

Create a Signed JWT and validate it in Istio using a JWK

This guide shows how to create a public/private key pair and how to use these to create a JWK and a signed JWT and then validate a request to a service running in Kubernetes where Istio is running.

Generate a Public/Private Key Pair

For this you’ll need openssl installed. If you have Chocolatey installed, choco install openssl -y will do the job.

Run the following commands:

ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
# Don't add passphrase
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub

This produces a .key file containing the private key and a .key.pub file containing the public key.

Create JWK from Public Key

Go to the JWK Creator site and paste in the contents of your public key. For purpose, choose Signing and for the algorithm, choose RS256.

Manifests

The following will secure all workloads where they have a label of type and a value of api so they must have a JWT and it must be valid.

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: "validate-jwt"
  namespace: istio-system
spec:
  selector:
    matchLabels:
      type: api
  jwtRules:
  - issuer: "testing@secure.istio.io"
    jwks: |
      {
        "keys": [
          <YOUR_JWK_GOES_HERE>
        ]
      }

---

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: "deny-requests-without-jwt"
  namespace: istio-system
spec:
  selector:
    matchLabels:
      type: api
  action: DENY
  rules:
  - from:
    - source:
        notRequestPrincipals: ["*"]

Replace <YOUR_JWK_GOES_HERE> with your JWK created in the previous step. Make sure your indentation doesn’t go to less than the existing { below the jwks: | line.

Create a Test JWT

The following PowerShell script will create a JWT that will last 3 hours and print it to the screen.

Clear-Host

# Install-Module jwtPS -force

Import-Module -Name jwtPS

$encryption = [jwtTypes+encryption]::SHA256
$algorithm = [jwtTypes+algorithm]::HMAC
$alg = [jwtTypes+cryptographyType]::new($algorithm, $encryption)

$key = "jwtRS256.key"
# The content must be joined otherwise you would have a string array.
$keyContent = (Get-Content -Path $key) -join ""
$payload = @{
    aud = "jwtPS"        
    iss = "testing@secure.istio.io"
    sub = "testing@secure.istio.io"
    nbf = 0
    groups = @(
      "group1",
      "group2"    
    )
    exp = ([System.DateTimeOffset]::Now.AddHours(3)).ToUnixTimeSeconds()
    iat = ([System.DateTimeOffset]::Now).ToUnixTimeSeconds()
    jti = [guid]::NewGuid()
}
$encryption = [jwtTypes+encryption]::SHA256
$algorithm = [jwtTypes+algorithm]::RSA
$alg = [jwtTypes+cryptographyType]::new($algorithm, $encryption)
$jwt = New-JWT -Payload $payload -Algorithm $alg -Secret $keyContent

Write-Host $jwt

Note that you will likely need to run the line Install-Module jwtPS -force in an elevated prompt.

Testing

To test this, you will need a service you can point at in your cluster with a label of type set to api. Don’t forget to update URLs as needed.

$TOKEN = jwt_from_previous_script
curl https://example.com/api/v1/test
curl --header "Authorization: Bearer $TOKEN" https://example.com/api/v1/test

In the above examples, the first request should return 401 and the second request should return 200.

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.