Testing ViewModels

When unit testing ViewModels, it is best to break the tests into individual test classes that represent each ViewModel, resulting in a one-to-one relationship between ViewModel classes and the unit test classes that test their logic.

In order to test our ViewModels, we will need to add a reference to them within the unit tests project, as shown in the following screenshot:

We will start by creating a set of unit tests for DetailViewModel:

  1. First, create a new folder in the TripLog.Tests project named ViewModels. This helps keep the file structure of the tests the same as the library being tested.
  2. Next, create a new empty class named DetailViewModelTests within the new
    ViewModels folder in the TripLog.Tests project.
  3. Next, update the DetailViewModelTests class with a TestFixture attribute:
      [TestFixture]
public class DetailViewModelTests
{
}
  1. Then, create a test setup method in the DetailViewModelTests class by adding a new method named Setup with the [SetUp] NUnit attribute, as follows:
      [TestFixture]
public class DetailViewModelTests
{
[SetUp]
public void Setup()
{
}
}

This Setup method will be responsible for creating new instances of our ViewModel for each of the tests within the class by ensuring that each test is run with a clean, known state of the ViewModel under test.

In order to create a new instance of a ViewModel, we will need to provide it with the instances of the services required by its constructor. During runtime, these are automatically provided via constructor injection, but in the case of the unit tests, we will need to provide them manually. We have a couple of options for passing in these services. We can create new mock versions of our services and pass them into the ViewModel's constructor. This requires providing a mock implementation for each method in the service's interface, which can be time-consuming and causes additional code maintenance. We can also use a mocking library to create mocks of the services and pass these mocks into the ViewModel's constructor. The mocking library provides a much cleaner approach that is also less fragile. Additionally, most mocking libraries will provide a way to specify how methods or properties should return data in a much cleaner way without actually having to implement them ourselves. I like to use Moq (available on NuGet)—a very popular mocking library for .NET applications—to handle mocking for my unit tests.

In order to initialize the ViewModel with mocked services, perform the following steps:

  1. Add a reference to the Moq NuGet package to the TripLog.Tests project.
  2. Next, within the Setup method, create a new instance of DetailViewModel and use the Moq library to create a mock instance of INavService to pass in when instantiating DetailViewModel:
      [TestFixture]
public class DetailViewModelTests
{
DetailViewModel _vm;

[SetUp]
public void Setup()
{
var navMock = new Mock<INavService>().Object;
_vm = new DetailViewModel(navMock);
}
}

Now that we have a setup function defined, we can create an actual test method. This ViewModel does not do much beyond initialization. Therefore, we will just test the Init method to ensure that the ViewModel is properly initialized when its Init method is called. The success criteria for this particular test will be that once Init is called, the Entry property of the ViewModel will be set to the value provided in the Init method's parameter.

In order to create a test for the ViewModel's Init method, perform the following steps:

  1. Create a new method in DetailViewModelTests named Init_ParameterProvided_EntryIsSet and decorate it with an NUnit Test attribute. Each test method that we create will follow the Arrange-Act-Assert pattern:
      [TestFixture]
public class DetailViewModelTests
{
// ...

[Test]
public async Task Init_ParameterProvided_EntryIsSet()
{
// Arrange

// Act

// Assert
}
}
  1. Next, update the arrange portion of the test method by creating a new mocked instance of a TripLogEntry object to pass to the Init method in order to test its functionality. Also, set the ViewModel's Entry property to null so that we can easily confirm that the property has a proper value after calling Init later in the assert portion of the test, as follows:
      [Test]
public async Task Init_ParameterProvided_EntryIsSet()
{
// Arrange
var mockEntry = new Mock<TripLogEntry>().Object;
_vm.Entry = null;

// Act

// Assert
}
  1. Next, pass the mocked TripLogEntry object into the ViewModel's Init method in the act portion of the test method:
      [Test]
public async Task Init_ParameterProvided_EntryIsSet()
{
// Arrange
var mockEntry = new Mock<TripLogEntry>().Object;
_vm.Entry = null;

// Act
await _vm.Init(mockEntry);

// Assert
}
  1. Finally, verify that the ViewModel's Entry property is no longer null using the NUnit Assert.IsNotNull method: 
      [Test]
public async Task Init_ParameterProvided_EntryIsSet()
{
// Arrange
var mockEntry = new Mock<TripLogEntry>().Object;
_vm.Entry = null;

// Act
await _vm.Init(mockEntry);

// Assert
Assert.IsNotNull(_vm.Entry, "Entry is null after being initialized with a valid TripLogEntry object");
}


There are several other Assert methods, such as AreEqual, IsTrue, and IsFalse, that can be used for various types of assertions.

Notice the second parameter in the Assert.IsNotNull method usage in step 4, which is an optional parameter. This allows you to provide a message to be displayed if the test fails to help troubleshoot the code under the test.

We should also include a test to ensure that the ViewModel throws an exception if the empty Init method is called, because the DetailViewModel requires the use of the Init method in the base class that takes a parameter. We can do this using the Assert.Throws NUnit method and providing a delegate that calls the Init method:

[Test]
public void Init_ParameterNotProvided_ThrowsEntryNotProvidedException()
{
// Assert
Assert.Throws(typeof(EntryNotProvidedException), async () =>
{
await _vm.Init();
});
}

Initially, this test will fail because until this point, we have not included the code to throw an EntryNotProvidedException in DetailViewModel. In fact, the tests currently will not even build because we have not defined the EntryNotProvidedException type.

In order to get the tests to build, create a new class in the core library that inherits from Exception and name it EntryNotProvidedException:

public class EntryNotProvidedException : Exception
{
public EntryNotProvidedException()
: base("An Entry object was not provided. If using DetailViewModel, be sure to use the Init overload that takes an Entry parameter.")
{
}
}

For ViewModels that have dependencies on a specific functionality of a service, you will need to provide some additional setup when you mock the objects for its constructor. For example, the NewEntryViewModel depends on the GetGeoCoordinatesAsync method of ILocationService in order to get the user's current location in the Init method. By simply providing a new Mock object for ILocationService to ViewModel, this method will return null, and an exception will be thrown when setting the Latitude and Longitude properties.

In order to overcome this, we will just need to use the Setup method when creating Mock to define how the calls to the GetGeoCoordinatesAsync method should be returned to the callers of the mock ILocationService instance:

  1. Create a new class in the TripLog.Tests project named NewEntryViewModelTests. Add the TextFixture attribute to the class, just as we did with the DetailViewModelTests class:
      [TestFixture]
public class NewEntryViewModelTests
{
}
  1. Next, create a method named Setup with the SetUp attribute, where we will define the NewEntryViewModel instance that will be used by tests in the class. NewEntryViewModel requires three parameters. We will use Moq again to provide mock implementations for them, but we will need to customize the implementation for ILocationService to specify exactly what the GetGeoCoordinatesAsync method should return:
      [TestFixture]
public class NewEntryViewModelTests
{
NewEntryViewModel _vm;

[SetUp]
public void Setup()
{
var navMock = new Mock<INavService>();
var dataMock = new Mock<ITripLogDataService>();
var locMock = new Mock<ILocationService>();
locMock.Setup(x => x.GetGeoCoordinatesAsync())
.ReturnsAsync(new GeoCoords
{
Latitude = 123,
Longitude = 321
});

_vm = new NewEntryViewModel(navMock.Object,
locMock.Object,
dataMock.Object);

}
}

Now that we know our mock ILocationService implementation will return 123 for Latitude and 321 for Longitude, we can properly test the ViewModel's Init method and ensure that the Latitude and Longitude properties are properly set using its provided ILocationService (this would be an actual platform-specific implementation when running the mobile app).

Following the Arrange-Act-Assert pattern, set the values of the Latitude and Longitude properties to 0 before calling the Init method. In the assert portion of the test, confirm that after calling Init, the Latitude and Longitude properties of ViewModel are the values that we expect to come from the provided mock ILocationService instance—in our case, 123 and 321:

[TestFixture]
public class NewEntryViewModelTests
{
// ...

[Test]
public async Task Init_EntryIsSetWithGeoCoordinates()
{
// Arrange
_vm.Latitude = 0.0;
_vm.Longitude = 0.0;

// Act
await _vm.Init();

// Assert
Assert.AreEqual(123, _vm.Latitude);
Assert.AreEqual(321, _vm.Longitude);
}
}

It is important to recognize that we are not testing the actual result or functionality of the ILocationService method—we are testing the behavior of the Init method which depends on the ILocationService method. The best way to do this is with mock objects—especially for platform-specific services or services that provide dynamic or inconsistent data.

As you can see in the preceding code, the use of dependency injection in the app architecture makes it extremely easy to test our ViewModels with maximum flexibility and minimum code.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.226.251.70