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 theExecute_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 withSetup(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 ourStudentListCommand
. - 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 ourStudentListCommand.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 fromExecute
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 aStudenListCommand
with a null repository (lines #87-94). This test verifies that constructor throws anArgumentNullException
in that case. That is exactly what theAssert.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 aCommandSettings
so we also need to create this object in the arrange section (line 23). - We create an instance of
StudentDeleteCommand.Settings
with a singleId
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 theStudentDeleteCommand
when the specifiedId
is not in the repository. It does this by intercepting the mockDeleteStudent
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 aSettings
class. But itsSettings
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 theStudentDeleteCommandTests
.- 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.
- We get student information about an existing
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.