Lesson 1.9: Unit Testing Commands

The detailed description for just one test and how to run it got longer than expected, so we are putting the remaining command tests in this separate lesson. We won’t go over each test in excruciating detail in this lesson. Instead, we will only focus on the differences or uniqueness of particular tests. For detailed explanation of how the layout of the unit test, please read Lesson 1.8.

Reminder: you can find the source for this lesson in our Azure DevOps repo:Cli.Lesson1.6.UnitTests – Repos (azure.com). And these tests are targeting the source code in the Cli.Lesson1.6 project.

Remaining StudentListCommand Tests

With our first test under our belt, let’s take a look at full implementation of the StudentListCommandTests.

using Cli.Lessons.Commands;
using Cli.Lessons.Models;
using Cli.Lessons.Services;
using Moq;
using Spectre.Console.Cli;
using Spectre.Console;
using System;
using System.Collections.Generic;
using Xunit;

namespace Cli.Lesson1._6.UnitTests.Commands
{
    [Collection("AnsiConsoleTests")]
    public class StudentListCommandTests
    {
        private readonly IList<Student> _testStudents = new List<Student>
        {
            new Student { Id = 1, FirstName = "Test1", LastName = "Surname1", EnrollmentDate = DateTimeOffset.Now },
            new Student { Id = 2, FirstName = "Test2", LastName = "Surname2", EnrollmentDate = DateTimeOffset.Now },
            new Student { Id = 3, FirstName = "Test3", LastName = "Surname3", EnrollmentDate = DateTimeOffset.Now },
            new Student { Id = 4, FirstName = "Test4", LastName = "Surname4", EnrollmentDate = DateTimeOffset.Now },
            new Student { Id = 5, FirstName = "Test5", LastName = "Surname5", EnrollmentDate = DateTimeOffset.Now },
        };

        private readonly IRemainingArguments _remainingArgs = new Mock<IRemainingArguments>().Object;

        [Fact]
        public void Execute_WithEmptyList()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>().Object;
            var command = new StudentListCommand(repo);
            var context = new CommandContext(_remainingArgs, "list", null);
            AnsiConsole.Record();

            // act
            var result = command.Execute(context);

            // assert
            Assert.Equal(0, result);
            var text = AnsiConsole.ExportText();
            Assert.Contains("# Students: 0", text);
        }

        [Fact]
        public void Execute_WithListOf5()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.GetStudents()).Returns(_testStudents);
            var command = new StudentListCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "list", null);
            AnsiConsole.Record();

            // act
            var result = command.Execute(context);

            // assert
            Assert.Equal(0, result);
            var text = AnsiConsole.ExportText();
            Assert.Contains("# Students: 5", text);
            Assert.Contains("1   Test1   Surname1", text);
            Assert.Contains("2   Test2   Surname2", text);
            Assert.Contains("3   Test3   Surname3", text);
            Assert.Contains("4   Test4   Surname4", text);
            Assert.Contains("5   Test5   Surname5", text);
        }

        [Fact]
        public void Execute_HandlesExceptions()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>(MockBehavior.Strict).Object;
            var command = new StudentListCommand(repo);
            var context = new CommandContext(_remainingArgs, "list", null);
            AnsiConsole.Record();

            // act
            var result = command.Execute(context);

            // assert
            Assert.Equal(-1, result);
            var text = AnsiConsole.ExportText();
            Assert.DoesNotContain("# Students:", text);
        }

        [Fact]
        public void Constructor_WithNullRepository()
        {
            // arrange

            // act/assert
            Assert.Throws<ArgumentNullException>(() => _ = new StudentListCommand(null));
        }
    }
}
  • After testing the StudentListCommand with an empty repository, we need to also test the command with a list of students. That’s what the Execute_WithListOf5 method does (lines #45-67).
    • This test is similar in structure to our first test, but with some additional configuration. In line #49, we still construct the mock object.
    • In line #50, we configure the mock repository using the Mock<T>.Setup method. This method allows us to intercept a request for an IUniversityRepository method.
    • For this test, we need to intercept the GetStudents method call. We represent that with Setup(f => f.GetStudents()).
    • So when that method is called on this mock repository, we are going to return the _testStudents class member. This returns 5 test students to any callers of this mock object… like our StudentListCommand.
    • The _testStudents are defined as static data in lines #16-23). By returning this list, we know exactly what student data will be returned to our StudentListCommand.Execute method in this test.
    • Our calling code remains the same, and so does the result returned from Execute. It still returns success (0) verified in line #59).
    • Line #61 verifies that we display the student count of 5 in the console.
    • And lines #62-66 verify that each student id, first name, and last name are displayed in the console.
  • Then, the Execute_HandlesExceptions method validates what happens if the repository were to throw an exception (lines #69-85).
    • As we will notice, this test looks almost exactly like the test for an empty list.
    • But line #73 creates the mock object with MockBehavior.Strict set. This setting makes all of the calls to the mock object throw an exception. This is a great feature to test error handling in our commands.
    • With the Execute method now handling an exception, we need to change our expected results. The return value from Execute is now -1 (line #82), meaning the command failed.
    • And, the “# Students:” is no longer displayed in the console. The error message from the exception is shown instead.
  • Finally, the Constructor_WithNullRepository method attempts to create a StudenListCommand with a null repository (lines #87-94). This test verifies that constructor throws an ArgumentNullException in that case. That is exactly what the Assert.Throws<ArgumentNullException> (line #93) from xUnit does.

With these 4 test cases, we are able to validate all of the functional behavior of our StudentListCommand class.

StudentDeleteCommand Tests

Next, we will create the StudentDeleteCommandTests class in the Commands folder.

using Cli.Lessons.Commands;
using Cli.Lessons.Services;
using Moq;
using Spectre.Console;
using Spectre.Console.Cli;
using System;
using Xunit;

namespace Cli.Lesson1._6.UnitTests.Commands
{
    [Collection("AnsiConsoleTests")]
    public class StudentDeleteCommandTests
    {
        private readonly IRemainingArguments _remainingArgs = new Mock<IRemainingArguments>().Object;

        [Fact]
        public void Execute_WithExistingId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            var command = new StudentDeleteCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "delete", null);
            var settings = new StudentDeleteCommand.Settings { Id = 3 };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("Delete operation succeeded!", AnsiConsole.ExportText());
        }

        [Fact]
        public void Execute_WithMissingId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.DeleteStudent(It.IsAny<int>())).Throws<ArgumentException>();
            var command = new StudentDeleteCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "delete", null);
            var settings = new StudentDeleteCommand.Settings { Id = 42 };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(-1, result);
            Assert.Contains("System.ArgumentException", AnsiConsole.ExportText());
        }

        [Fact]
        public void Constructor_WithNullRepository()
        {
            // arrange

            // act/assert
            Assert.Throws<ArgumentNullException>(() => _ = new StudentDeleteCommand(null));
        }
    }
}
  • For the Execute_WithExistingId method, we are using a CommandSettings so we also need to create this object in the arrange section (line 23).
  • We create an instance of StudentDeleteCommand.Settings with a single Id property.
  • Then we pass that settings object to the Execute method in line #27, which simulates how our command gets called by the Spectre.Command package.
  • The Execute_WithMissingId method tests the behavior of the StudentDeleteCommand when the specified Id is not in the repository. It does this by intercepting the mock DeleteStudent call and throwing the appropriate exception (line #39).

StudentUpsertCommand Tests

Then create the StudneUpsertCommandTests class in the Commands folder.

using Cli.Lessons.Commands;
using Cli.Lessons.Models;
using Cli.Lessons.Services;
using Moq;
using Spectre.Console;
using Spectre.Console.Cli;
using System;
using Xunit;

namespace Cli.Lesson1._6.UnitTests.Commands
{
    [Collection("AnsiConsoleTests")]
    public class StudentUpsertCommandTests
    {
        private readonly IRemainingArguments _remainingArgs = new Mock<IRemainingArguments>().Object;

        [Fact]
        public void Execute_WithNewId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            var command = new StudentUpsertCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "new", null);
            var settings = new StudentUpsertCommand.Settings { Id = 10, FirstName = "Darth", LastName = "Pedro" };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("Create/edit operation succeeded!", AnsiConsole.ExportText());
        }

        [Fact]
        public void Execute_WithExistingId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            var command = new StudentUpsertCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "edit", null);
            var settings = new StudentUpsertCommand.Settings
                { Id = 10, FirstName = "Darth", LastName = "Pedro", EnrollmentDate = new DateTime(2020, 5, 3) };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("Create/edit operation succeeded!", AnsiConsole.ExportText());
        }

        [Fact]
        public void Execute_WithException()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.UpsertStudent(It.IsAny<Student>())).Throws<ArgumentException>();
            var command = new StudentUpsertCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "new", null);
            var settings = new StudentUpsertCommand.Settings { Id = 42, FirstName = "foo", LastName = "bar" };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(-1, result);
            Assert.Contains("System.ArgumentException", AnsiConsole.ExportText());
        }

        [Fact]
        public void Constructor_WithNullRepository()
        {
            // arrange

            // act/assert
            Assert.Throws<ArgumentNullException>(() => _ = new StudentUpsertCommand(null));
        }
    }
}
  • The StudenUpsertTests also have a Settings class. But its Settings class is a little more complicated setup because it has properties for all of the fields for a student. Creating that class is similar to the previous test class, but just includes all of the information about the student. We use those settings in a few different tests in this class.
  • The remaining tests are similar to other test classes and validate expected error conditions.

StudentViewCommand Tests

Finally, create the StudentViewCommandTests class in the Commands folder.

using Cli.Lessons.Commands;
using Cli.Lessons.Models;
using Cli.Lessons.Services;
using Moq;
using Spectre.Console;
using Spectre.Console.Cli;
using System;
using Xunit;

namespace Cli.Lesson1._6.UnitTests.Commands
{
    [Collection("AnsiConsoleTests")]
    public class StudentViewCommandTests
    {
        private readonly Student _testStudent = new Student
        {
            Id = 3,
            FirstName = "Test3",
            LastName = "Surname3",
            EnrollmentDate = DateTimeOffset.Now
        };

        private readonly IRemainingArguments _remainingArgs = new Mock<IRemainingArguments>().Object;

        [Fact]
        public void Execute_WithExistingId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.GetStudentById(It.IsAny<int>())).Returns(_testStudent);
            var command = new StudentViewCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "view", null);
            var settings = new StudentViewCommand.Settings { Id = 3 };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("View Student => id[3]", AnsiConsole.ExportText());
            Assert.Contains("Id: 3", AnsiConsole.ExportText());
            Assert.Contains("First Name: Test3", AnsiConsole.ExportText());
            Assert.Contains("Last Name: Surname3", AnsiConsole.ExportText());
        }

        [Fact]
        public void Execute_WithMissingId()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.GetStudentById(It.IsAny<int>())).Returns<Student>(null);
            var command = new StudentViewCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "view", null);
            var settings = new StudentViewCommand.Settings { Id = 42 };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("Student not found with id = 42.", AnsiConsole.ExportText());
        }

        [Fact]
        public void Execute_WithException()
        {
            // arrange
            var repo = new Mock<IUniversityRepository>();
            repo.Setup(f => f.GetStudentById(It.IsAny<int>())).Throws<ArgumentException>();
            var command = new StudentViewCommand(repo.Object);
            var context = new CommandContext(_remainingArgs, "view", null);
            var settings = new StudentViewCommand.Settings { Id = 404 };
            AnsiConsole.Record();

            // act
            var result = command.Execute(context, settings);

            // assert
            Assert.Equal(-1, result);
            Assert.Contains("System.ArgumentException", AnsiConsole.ExportText());
        }

        [Fact]
        public void Constructor_WithNullRepository()
        {
            // arrange

            // act/assert
            Assert.Throws<ArgumentNullException>(() => _ = new StudentViewCommand(null));
        }
    }
}
  • The tests for StudentViewCommandTests are logically similar to the StudentDeleteCommandTests.
    • We get student information about an existing Id and verify that it is displayed as we expect.
    • And if we use an Id that is not already in the repository, then we ensure that the user gets an error message.

With all of the tests implemented, it’s time to build and run our tests again. If we follow the Test Explorer steps in Lesson 1.8, we can run all of the tests again.

In the Test Explorer, click the ‘Play All’ button in the toolbar. That should start all of the tests running. After a couple of seconds they should all pass (19 green tests).

Collection Attribute Explained

In all of our test classes, we have placed the [Collection("AnsiConsoleTests")] attribute. This places all of the tests in these classes in the same collection. Without that attribute, each class’s tests run in their own collection. Usually that’s the behavior we want because they can run in parallel and more quickly. Placing these tests in the same collection makes them all run sequentially.

We did this because our tests are all dependent on AnsiConsole… a singleton console class from Spectre.Console. If our test classes run in parallel sometimes the output in the console isn’t our expected results because multiple threads are writing to the same singleton. So the [Collection] attribute is a quick way to make the tests run in a predictable fashion.

This isn’t always the ideal for running hundreds of unit tests, but it works in our small console application. Another approach would be to replace the implementation of AnsiConsole with a mocked test console (by implementing the IAnsiConsole interface) and then passing that into our command constructors. That would use dependency injection similar to our repository and allow us to pass a test IAnsiConsole in our unit tests. In the future, I may look at creating such a test console to help keep tests stable and provide a way to validate the data written into the console, but still allow the tests to run in parallel. But that’s a task for another time…

These last three lessons showed how to build a robust set of unit tests for our commands. We were able to easily test the commands in isolation by mocking the IUniversityRepository interface in our tests. This gave us the ability to return any value we wanted or drive error handling code via exceptions. Our ability to test commands in isolation was designed into the Spectre.Console package. It’s modular design and use of dependency injection made commands very testable.

In the next lesson, we will learn about building some end to end tests that validate our command line functionality from the user’s perspective, which will also cover all of the code for configuring commands and setting up the dependency injection.

Leave a Reply

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

WordPress.com Logo

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

Facebook photo

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

Connecting to %s