Lesson 1.10: CLI End to End Tests

Unit tests are just one type of test that we can use to validate our console application. While those are validating that command logic is working in isolation, we also need to verify that our command configuration was done correctly, that our repository is working, and that the command line works the way our users would use it. Those are a different type of test usually called integration (or end to end) tests.

In this lesson, we are going to build a few end-to-end tests that validate our system works from the perspective of our users. Our tests will pass command line parameters to the system and validate the expected results. This will ensure that all of the components are integrated correctly, our commands are configured, and those commands use the IUniversityRepository correctly. These are longer tests and validate scenarios and not individual concepts.

This is another important layer of testing that gives us a different perspective of the console application’s correctness that can’t be validated in unit tests.

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.

In most cases, these integration tests are placed in their own project and run separately, since they may be more time consuming. But for the sake of simplicity and because our console application is so small, we will include these tests in our Cli.Lesson1.6.UnitTests project as well.

UniversityRepository Tests

We want to write targeted tests for the UniversityRepository to ensure that code is working as expected and all of the conditions are covered. These are not technically unit tests because they have a dependency on the file system to save and load data. And data from previous tests may impact future tests, so we need to be sure they begin in a clean state.

Let’s create the UniversityRepositoryTests class in the Services folder to match our source code structure.

using Cli.Lessons.Models;
using Cli.Lessons.Services;
using System;
using Xunit;

namespace Cli.Lesson1._6.UnitTests.Services
{
    public class UniversityRepositoryTests
    {
        private readonly Student _testStudent = new Student
        {
            Id = 1234,
            FirstName = "Test",
            LastName = "Surname",
            EnrollmentDate = DateTimeOffset.Now
        };

        [Fact]
        public void TestRepositoryOperations()
        {
            Test001();
            Test002();
            Test003();
            Test004();
        }

        [Fact]
        public void DeleteStudent_WithMissingId()
        {
            // arrange
            var repo = new UniversityRepository();

            // act/assert
            Assert.Throws<ArgumentException>(() => repo.DeleteStudent(404));
        }

        private void Test001()
        {
            // arrange
            var repo = new UniversityRepository();

            // act
            repo.UpsertStudent(_testStudent);
            var result = repo.GetStudentById(1234);

            // assert
            Assert.NotNull(result);
            Assert.Equal(1234, result.Id);
        }

        private void Test002()
        {
            // arrange
            var repo = new UniversityRepository();

            // act
            repo.UpsertStudent(_testStudent);
            var result = repo.GetStudentById(1234);

            // assert
            Assert.NotNull(result);
            Assert.Equal(1234, result.Id);
        }

        private void Test003()
        {
            // arrange
            var repo = new UniversityRepository();

            // act
            var result = repo.GetStudents();

            // assert
            Assert.NotNull(result);
            Assert.NotEmpty(result);
            Assert.Contains<Student>(result, s => s.Id == 1234);
        }

        private void Test004()
        {
            // arrange
            var repo = new UniversityRepository();

            // act
            repo.DeleteStudent(1234);
            var result = repo.GetStudentById(1234);

            // assert
            Assert.Null(result);
        }

    }
}
  1. First, notice that there are only two test methods defined in this class: TestRepositoryOperations and DeleteStudent_WithMissingId.
  2. The TestRepositoryOperations tests all of the repository operations. Because we want to minimize state between tests and leave our test environment in a clean state, we test all of the operations in one context.
  3. This method then calls individual steps to validate the creation and persistence of a Student, then viewing the student, getting the student list, and finally deleting the student.
  4. Deleting the student puts us back into a clean state.
  5. Finally, there is specific logic in the repository to throw an exception in the DeleteStudent method. So we have a separate test that validates that error condition.

We didn’t do it in this test class, but if we wanted to make our test passes even more robust, we could have deleted the repository data file (either at the construction or destruction of the test class) to be sure to start the next test run in a known clean state.

Command Line Tests

Then, we create the End2EndTests class in the project root. These are the end-to-end tests that simulate our users passing parameters to our command-line app.

using Cli.Lessons;
using Spectre.Console;
using Xunit;

namespace Cli.Lesson1._6.UnitTests
{
    [Collection("AnsiConsoleTests")]
    public class End2EndTests
    {
        [Fact]
        public void ListStudents()
        {
            // arrange
            var args = new string[] { "student", "list" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

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

        [Fact]
        public void CrudStudents()
        {
            AddStudent();
            ViewStudent();
            UpdateStudent();
            ViewStudent2();
            DeleteStudent();
        }

        private void AddStudent()
        {
            // arrange
            var args = new string[] { "student", "add", "101", "Darth", "Pedro" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

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

        private void ViewStudent()
        {
            // arrange
            var args = new string[] { "student", "view", "101" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

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

        private void UpdateStudent()
        {
            // arrange
            var args = new string[] { "student", "edit", "101", "Darth", "Pedro2" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

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

        private void ViewStudent2()
        {
            // arrange
            var args = new string[] { "student", "view", "101" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

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

        private void DeleteStudent()
        {
            // arrange
            var args = new string[] { "student", "delete", "101" };
            AnsiConsole.Record();

            // act
            var result = Program.Main(args);

            // assert
            Assert.Equal(0, result);
            Assert.Contains("Delete operation succeeded!", AnsiConsole.ExportText());
        }
    }
}
  1. We start with the simplest operation to test with the ListStudents method (lines #10-25).
  2. We define the command-line arguments for the list operation in an array (line #14), which is simply the root command “student” and the “list” sub-command. This is how the command-line parameters would be sent to our Main method by the .NET launcher.
  3. And, we start to record the AnsiConsole output as we did in our unit tests (line #15).
  4. Then, we directly call the Program.Main method with those command-line arguments. (line #18). By calling through the Main method:
    • We ensure that our command configuration code is being called.
    • We ensure that the dependency injection is registered as expected.
    • We ensure that the command-line arguments that users would use are being mapped correctly to the correct command and parameters, and are being executed as expected.
    • In other words, running the command-line app end-to-end.
  5. We validate the success (0) return value from the app.
  6. And, we check that console contains the text output we expected.

Our second test, CrudStudents, validates all of the create-read-update-delete (CRUD) commands are working as expected (lines #27-35).

  1. That is the one root test method, but it fans out to call individual private methods for each command.
  2. It calls the methods in an expected order so that the Student is created, then retrieved, and finally deleted.
  3. Each individual private method is a separate call to Program.Main method with the appropriate command-line arguments. Doing this validates that the data is being persisted correctly in our repository between sessions.
  4. Each method validates the expected console output for its command.

This covers our integration/end-to-end tests for our console application. We now have a set of 19 tests that cover all of our unit test and end-to-end test scenarios. If we had more commands or very complex logic in those commands, we would need many more tests to cover everything. But this is a good starting point to understand how to build tests for console applications.

Run the Tests

With all of our tests in place, we can build and run the entire test suite. We do that by clicking the ‘Play All’ button in the Test Explorer (for detailed steps on how to do this, see Lesson 1.8). With a successful run, we should have 19/19 passing tests.

Fig 1 – Test Results

This concludes our 4 lesson arc into testing command-line apps. We learned about useful tools for automated testing and mock objects. And we showed how to build a suite of tests with those frameworks. As you build your own console applications, the lessons from these tests should be applicable to your apps. Testing is an important aspect of any development project, so it’s great to see that Spectre.Console.Cli is designed to make testing easy.

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