Tutorial: Learn different ways to create a unit test cases using NUnit and C# with code samples.
As a developer, given the best practices of coding, it is vital to write unit test cases, to ensure that every piece of code works for all scenarios. Also, it makes functional and user acceptance testing a breeze.
There are innumerable ways to write a unit test. For this case, we’ll be using NUnit + MOQ library combination. These libraries allow you to automate the test cases, thereby making sure you don’t miss any scenarios.
Let us consider an example, a Library Management System, to discuss the different ways of writing unit cases and evaluate each option.
Scenario: create unit tests in a Library Management System
Assume that you have a library and your application is tracking the books borrowed by persons.
To keep things simple, let us have these entities:
- Books: to store the books that are present in the library.
- Persons: to save the list of library members.
- Lendings: to map books to persons and when they lent/returned the books.
https://github.com/koditus/advanced-unit-tests/blob/master/Services/Models/Book.cs
public class Book { public int Id { get; set; } public string Name { get; set; } }
https://github.com/koditus/advanced-unit-tests/blob/master/Services/Models/Person.cs
public class Person { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string EmailAddress { get; set; } }
https://github.com/koditus/advanced-unit-tests/blob/master/Services/Models/Lending.cs
public class Lending { public int Id { get; set; } public Book Book { get; set; } public Person Person { get; set; } public DateTime StartDate { get; set; } public DateTime? EndDate { get; set; } }
SetUp using NUnit
To begin, we first need to set up our SUT (system under test, and dependency mocks). This is done using the method with attribute [SetUp].
SetUp defines the function that we need to call before each unit test. For example, in our library management system, I can use set up to initialize a repository for books, persons and lending.
NUnit documentation: https://github.com/nunit/docs/wiki/SetUp-Attribute
https://github.com/nunit/docs/wiki/SetUp-and-TearDown
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/LendingServiceTestsBase.cs
public class LendingServiceTestsBase { protected const int ValidBookId = 1; protected const int InvalidBookId = 6; protected const int ValidPersonId = 1; protected const int InvalidPersonId = 6; protected const int LentBookId = 7; protected const int ReturnedBookId = 8; protected LendingService LendingService; private Dictionary<int, Lending> _lendings; private Mock<ILendingRepository> _mockLendingRepository; private Mock<IBookRepository> _mockBookRepository; private Mock<IPersonRepository> _mockPersonRepository; private int NextLendingId => _lendings.Keys.Append(0).Max() + 1; [SetUp] public void SetUp() { _lendings = new Dictionary<int, Lending> { { 1, new Lending { Book = new Book { Id = LentBookId }, EndDate = null, Id = 1, Person = new Person { Id = ValidPersonId } } } }; _mockLendingRepository = new Mock<ILendingRepository>(); _mockLendingRepository.Setup(repo => repo.GetAll()).Returns(() => _lendings.Values.ToArray()); _mockLendingRepository.Setup(repo => repo.Add(It.IsAny<Lending>())) .Callback<Lending>(lending => { lending.Id = NextLendingId; _lendings.Add(lending.Id, lending); }) .Returns((Lending lending) => lending); _mockLendingRepository.Setup(repo => repo.Update(It.IsAny<Lending>())) .Callback<Lending>(lending => _lendings[lending.Id] = lending) .Returns<Lending>(lending => _lendings[lending.Id]); _mockBookRepository = new Mock<IBookRepository>(); _mockBookRepository.Setup(repo => repo.GetOne(ValidBookId)) .Returns((int bookId) => new Book { Id = bookId }); _mockBookRepository.Setup(repo => repo.GetOne(LentBookId)) .Returns((int bookId) => new Book { Id = bookId }); _mockBookRepository.Setup(repo => repo.GetOne(ReturnedBookId)) .Returns((int bookId) => new Book { Id = bookId }); _mockBookRepository.Setup(repo => repo.GetOne(InvalidBookId)) .Returns<Book>(null); _mockPersonRepository = new Mock<IPersonRepository>(); _mockPersonRepository.Setup(repo => repo.GetOne(ValidPersonId)) .Returns((int parameter) => new Person { Id = parameter }); _mockPersonRepository.Setup(repo => repo.GetOne(InvalidPersonId)) .Returns<Person>(null); LendingService = new LendingService(_mockLendingRepository.Object, _mockBookRepository.Object, _mockPersonRepository.Object); } protected void AssertBookIsValid(Lending lending, int bookId, int personId) { Assert.IsNotNull(lending); Assert.AreEqual(bookId, lending.Book.Id); Assert.AreEqual(personId, lending.Person.Id); } protected void AssertBookLent(Lending lending, int bookId, int personId) { AssertBookIsValid(lending, bookId, personId); Assert.IsNotNull(lending.StartDate); Assert.IsNull(lending.EndDate); } protected void AssertBookReturned(Lending lending, int bookId, int personId) { AssertBookIsValid(lending, bookId, personId); Assert.IsNotNull(lending.StartDate); Assert.IsNotNull(lending.EndDate); } protected void VerifyNoOtherCalls() { _mockBookRepository.VerifyNoOtherCalls(); _mockLendingRepository.VerifyNoOtherCalls(); _mockPersonRepository.VerifyNoOtherCalls(); } protected void VerifyBookRepository_GetOne_IsCalled(int times) { _mockBookRepository.Verify(mock => mock.GetOne(It.IsAny<int>()), Times.Exactly(times)); } protected void VerifyPersonRepository_GetOne_IsCalled(int times) { _mockPersonRepository.Verify(mock => mock.GetOne(It.IsAny<int>()), Times.Exactly(times)); } protected void VerifyLendingRepository_GetAll_IsCalled(int times) { _mockLendingRepository.Verify(mock => mock.GetAll(), Times.Exactly(times)); } protected void VerifyLendingRepository_Add_IsCalled(int times) { _mockLendingRepository.Verify(mock => mock.Add(It.IsAny<Lending>()), Times.Exactly(times)); } protected void VerifyLendingRepository_Update_IsCalled(int times) { _mockLendingRepository.Verify(mock => mock.Update(It.IsAny<Lending>()), Times.Exactly(times)); } }
Similar to Set Up, there is the TearDown attribute to run the functions after each unit test.
NUnit documentation: https://github.com/nunit/docs/wiki/TearDown-Attribute
https://github.com/nunit/docs/wiki/SetUp-and-TearDown
Adding Test Cases
Now that we have set up, let’s start adding our unit tests.
To create a test case an attribute [TestFixture] is added to the class containing the test functions. A similar attribute [Test] is added to the functions.
NUnit documentation links:
https://github.com/nunit/docs/wiki/TestFixture-Attribute
https://github.com/nunit/docs/wiki/Test-Attribute
Step 1:
Let’s check our primary “happy path” scenario, when an existing book is lent to an existing person.
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleOne.cs
[Test] public void GivenValidBookAndPersonIds_WhenLendingBookToAPerson_ThenBookGetsLent() { //Arrange //Act var lending = LendingService.Lend(ValidBookId, ValidPersonId); //Assert AssertBookIsValid(lending, ValidBookId, ValidPersonId); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(1); VerifyLendingRepository_GetAll_IsCalled(1); VerifyLendingRepository_Add_IsCalled(1); VerifyNoOtherCalls(); }
Step 2:
But what about if a person and a book doesn’t exist? Let’s test it!
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleOne.cs
[Test] public void GivenInvalidBookAndPersonIds_WhenLendingBookToAPerson_ThenGetException() { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(InvalidBookId, InvalidPersonId)); VerifyBookRepository_GetOne_IsCalled(1); VerifyNoOtherCalls(); }
Step 3:
But what about, if a person exists, but a book doesn’t? Or vice-versa? Or both?
Or if the book is already lent?
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleTwo.cs
namespace UnitTests { [TestFixture] public class ExampleTwo : LendingServiceTestsBase { [Test] public void GivenBookAlreadyLent_WhenLendingAgain_ThenGetException() { //Arrange //Act LendingService.Lend(ValidBookId, ValidPersonId); //Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(ValidBookId, ValidPersonId)); VerifyBookRepository_GetOne_IsCalled(2); VerifyPersonRepository_GetOne_IsCalled(2); VerifyLendingRepository_GetAll_IsCalled(2); VerifyLendingRepository_Add_IsCalled(1); VerifyNoOtherCalls(); } [Test] public void GivenInvalidBookIdAndValidPersonId_WhenLendingBookToAPerson_ThenGetException() { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(InvalidBookId, ValidPersonId)); VerifyBookRepository_GetOne_IsCalled(1); VerifyNoOtherCalls(); } [Test] public void GivenValidBookIdAndInvalidPersonId_WhenLendingBookToAPerson_ThenGetException() { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(ValidBookId, InvalidPersonId)); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(1); VerifyNoOtherCalls(); } } }
Step 4:
Now that we have lending functionality covered, let’s look at book returning functionality. Cases in which we are interested are: What if the book is lent and we want to return it(happy path)? Book is already returned and we try to return it again; We are trying to return non lent book;
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleThree.cs
namespace UnitTests { [TestFixture] public class ExampleThree : LendingServiceTestsBase { [Test] public void GivenBookAlreadyLent_WhenReturning_ThenBookGetsReturned() { //Arrange LendingService.Lend(ValidBookId, ValidPersonId); //Act var returnedLending = LendingService.Return(ValidBookId); //Assert AssertBookReturned(returnedLending, ValidBookId, ValidPersonId); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(1); VerifyLendingRepository_GetAll_IsCalled(2); VerifyLendingRepository_Add_IsCalled(1); VerifyLendingRepository_Update_IsCalled(1); } [Test] public void GivenReturnedBook_WhenReturning_ThenGetException() { //Arrange LendingService.Lend(ValidBookId, ValidPersonId); LendingService.Return(ValidBookId); //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Return(ValidBookId)); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(1); VerifyLendingRepository_GetAll_IsCalled(3); VerifyLendingRepository_Add_IsCalled(1); VerifyLendingRepository_Update_IsCalled(1); } [Test] public void GivenNonLentBook_WhenReturning_ThenGetException() { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Return(ValidBookId)); VerifyLendingRepository_GetAll_IsCalled(1); VerifyNoOtherCalls(); } } }
Step 5:
At the current state we have main functionality covered. Now can we make it simpler? Let’s give it a try by looking into lending functionality when a book or person is invalid and combine all those cases into one unit test using the [TestCase] attribute.
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleFour.cs
namespace UnitTests { [TestFixture] public class ExampleFour : LendingServiceTestsBase { [Test] [TestCase(InvalidBookId, ValidPersonId)] [TestCase(ValidBookId, InvalidPersonId)] [TestCase(InvalidBookId, InvalidPersonId)] public void GivenInvalidBookOrPersonIds_WhenLendingBookToAPerson_ThenGetException(int bookId, int personId) { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(bookId, personId)); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(bookId == InvalidBookId ? 0 : 1); VerifyNoOtherCalls(); } } }
NUnit documentation links:
https://github.com/nunit/docs/wiki/TestCase-Attribute
Step 6:
For keeping consistency we also rework the positive case with [TestCase] attribute even so it wouldn’t bring any value at this exact case.
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleFive.cs
[Test] [TestCase(ValidBookId, ValidPersonId)] public void GivenBookAndPersonIds_WhenLendingBookToAPerson_ThenLendsWithValidationChecks(int bookId, int personId) { //Arrange //Act var lending = LendingService.Lend(bookId, personId); //Assert AssertBookLent(lending, ValidBookId, ValidPersonId); VerifyBookRepository_GetOne_IsCalled(1); VerifyPersonRepository_GetOne_IsCalled(1); VerifyLendingRepository_GetAll_IsCalled(1); VerifyLendingRepository_Add_IsCalled(1); VerifyNoOtherCalls(); } } }
Step 7:
For trying to reach simplicity we used attributes and one may say that our unit tests actually became simpler. But sometimes it happens that you just get carried away with the idea of squeezing things together or just by the simple wish to try things out. And the result gets very messy. Let’s try to hypothetically imagine such a situation and make a unit test which would have all the unit test scenarios in one test. For that we will use the [Theory] attribute.
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleSix.cs
namespace UnitTests { [TestFixture] public class ExampleSix : LendingServiceTestsBase { [Test] [Theory] public void GivenBookStatus_WhenPerformingAction_ThenActionIsPerformedWithValidations(bool isLent, bool isReturned, bool isLending) { //Arrange if(isLent || isReturned) LendingService.Lend(ValidBookId, ValidPersonId); if(isReturned) LendingService.Return(ValidBookId); //Act Lending lending = null; Lending returnedLending = null; if(isLending) { if(isLent && !isReturned) Assert.Throws<InvalidOperationException>(() => LendingService.Lend(ValidBookId, ValidPersonId)); if(!isLent) lending = LendingService.Lend(ValidBookId, ValidPersonId); } else { if(isReturned || !isLent) Assert.Throws<InvalidOperationException>(() => LendingService.Return(ValidBookId)); if(!isReturned && isLent) returnedLending = LendingService.Return(ValidBookId); } //Assert if(isLending) { if(!isLent) { Assert.IsNotNull(lending); Assert.AreEqual(ValidBookId, lending.Book.Id); Assert.AreEqual(ValidPersonId, lending.Person.Id); Assert.IsNotNull(lending.StartDate); } } else { if(!isReturned && isLent) { Assert.IsNotNull(returnedLending); Assert.IsNotNull(returnedLending.EndDate); } } } } }
NUnit documentation links:
https://github.com/nunit/docs/wiki/Theory-Attribute
Step 8:
But what if we actually would try to push a little forward and make our all cases into simpler unit tests. Well one thing about making things simpler is that everyone may have a different understanding of what is simpler. So let’s try to make one possible way of many possibilities how our unit test’s can be simplified a little more. For that we will use [Sequential] and [Values] attributes.
https://github.com/koditus/advanced-unit-tests/blob/master/UnitTests/ExampleSeven.cs
namespace UnitTests { [TestFixture] public class ExampleSeven : LendingServiceTestsBase { [Test, Sequential] public void GivenInvalidBookOrPerson_WhenLending_ThenValidations( [Values(ValidBookId, InvalidBookId, LentBookId, ReturnedBookId)] int bookId, [Values(InvalidPersonId, ValidPersonId, ValidPersonId, InvalidPersonId)] int personId) { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Lend(bookId, personId)); } [Test, Sequential] public void GivenValidBookAndPerson_WhenLending_ThenLent([Values(ValidBookId, ReturnedBookId)] int bookId, [Values(ValidPersonId, ValidPersonId)] int personId) { //Arrange //Act var lending = LendingService.Lend(bookId, personId); //Assert AssertBookLent(lending, bookId, personId); } [Test, Sequential] public void GivenInvalidBooksForReturn_WhenReturning_ThenValidations([Values(ValidBookId, InvalidBookId, ReturnedBookId)] int bookId) { //Arrange //Act / Assert Assert.Throws<InvalidOperationException>(() => LendingService.Return(bookId)); } [Test, Sequential] public void GivenLentBook_WhenReturning_ThenBookReturned([Values(LentBookId)] int bookId) { //Arrange //Act / Assert var lending = LendingService.Return(bookId); //Assert AssertBookReturned(lending, bookId, ValidPersonId); } } }
NUnit documentation links:
https://github.com/nunit/docs/wiki/Sequential-Attribute
https://github.com/nunit/docs/wiki/Values-Attribute
Conclusions :
There are many ways to write unit tests. There are many unit tests frameworks. We are not trying to say that our presented way is the best or even a good one. We just want to bring some attention to them, because we as developers highly value them. Unit tests saved us countless production releases, prevented many bugs and saved a lot of money for our clients.