Analyzing, Improving, and
Testing a Small Program [Draft]
This article analyzes a simple
program and discusses how it can be improved and tested. The idea is to walk
through several iterations of a program to see how it evolves, which mirrors
iterations of refactoring I have performed during my own work.
The
program calls a web service to retrieve a list of builds and prints out the
highest ranking build, where the ranking of a build is the length of its build
number (the higher the better).
Builds
are queried by their id using the Visual Studio Team Services (VSTS) REST API
through the use of a Nuget package VSORestAPI. The build ids to query
are located in a text file called buildsIds.txt that exists on the
Desktop directory of the host machine. The program parses buildIds.txt
to retrieve the list of build ids to query, queries the builds, and then returns
the highest ranking build.
Initial
Version (Commentary)
Iteration
1 (Commentary)
Iteration
2 (Commentary)
Iteration
3 (Commentary)
Initial Version (No Unit Tests)
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using VSORestAPI;
public class Program
{
public static void Main(string[] args)
{
var account = args[0];
var project = args[1];
var bestBuild = GetTopRankedBuild(account, project);
Console.WriteLine($"The top ranked build is {bestBuild.BuildNumber}");
}
/// <summary>
/// Retrieves the top ranked build.
/// </summary>
/// <param name="account">the VSTS account the builds were
launched on</param>
/// <param name="project">the VSTS project the builds were
launched on</param>
/// <returns>the top ranked build, or null if there are no builds</returns>
internal static Build GetTopRankedBuild(string account, string project)
{
var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
var buildIds = File.ReadAllLines(Path.Combine(windowsDir, "buildIds.txt"));
var builds = new List<Build>();
foreach (var buildId in buildIds.Select(x => int.Parse(x)))
{
var build = BuildAPI.GetBuild(account, project, buildId);
builds.Add(build);
}
return builds.OrderByDescending(x =>
x.BuildNumber.Length).FirstOrDefault();
}
}
|
Commentary On Initial Version
Even the smallest of programs can fail in a surprisingly
large number of ways. Look through the code and try to identify assumptions that
lead to failure.
Points of Failure
The program can fail in many
ways:
·
If the program is passed less than two arguments, the index into args throws an IndexOutOfBoundsException
·
The arguments to GetTopRankedBuild are
not validated
·
If buildIds.txt does not exist, File.ReadAllLines throws
a FileNotFoundException
·
The content of buildIds.txt may
not contain the list integers the program is expecting, causing the call to int.Parse to throw a FormatException
·
The call to vsts.GetBuild may throw an exception. Without reading the documentation
of VSORestAPI,
we have no way of knowing which exceptions it may throw
·
If the build object returned by vsts.GetBuild is null or its BuildNumber property is null, the call to builds.OrderByDescending
throws a NullReferenceException
·
The path specified by Environment.SpecialFolder.Desktop may not be reliable. For example, can this location depend
on the logged on user?
·
If GetTopRankedBuild
returns null, Main
will crash trying to print the build number of
the best build
Dealing
with failure scenarios is difficult and frustrating. Unlike the happy path, how to handle a failure scenario is often undefined or
ambigious. For example, if buildIds.txt gets corrupted, how should the
program recover, if at all? For a small program such as this, letting it crash
and burn is often okay, but when writing code other programmers will take depend
on, it is important to define and handle each failure scenario.
Other
common assumptions that lead to failures include:
·
Concurreny assumption: my code is executed on a single thread
·
Globalization assumption: my code is executed in the same timezone
·
Environment assumption: my code is executed on the same hardware and software
stack
·
Localization assumption: my code is executed by English users only
Performance Concerns
There
are also serious performance concerns:
·
How large is buildIds.txt? For
each build id, the program makes an HTTP request to retrieve the build
·
How long does an HTTP request to VSTS
take?
o What happens if the service is offline or is taking a long
time to reply?
Unit Testing Impediments
·
Testing the program depends on making an HTTP call (or several)
to VSTS, thus any unit test requires an Internet connection and retrieving
real, persistent, data from VSTS
Iteration 1
Program (Iteration 1)
|
Unit Tests
(Iteration 1)
|
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using VSORestAPI;
public interface IVsts
{
Build GetBuild(string account, string project, int buildId);
}
public class HttpVsts : IVsts
{
public Build GetBuild(string account, string project, int buildId)
{
return BuildAPI.GetBuild(account, project, buildId);
}
}
public class Program
{
internal static IVsts vsts = new HttpVsts();
public static void Main(string[] args)
{
var account = args[0];
var project = args[1];
var bestBuild = GetTopRankedBuild(account, project);
if (bestBuild != null)
{
Console.WriteLine($"The top ranked build is {bestBuild.BuildNumber}");
}
else
{
Console.WriteLine("No builds on this machine");
}
}
/// <summary>
/// Retrieves the top ranked build.
/// </summary>
/// <param name="account">the VSTS account the builds were
launched on</param>
/// <param name="project">the VSTS project the builds were launched
on</param>
/// <returns>the top ranked build, or null if there are no builds</returns>
internal static Build GetTopRankedBuild(string account, string project)
{
if (string.IsNullOrWhiteSpace(account)) throw new ArgumentException(nameof(account));
if (string.IsNullOrWhiteSpace(project)) throw new ArgumentException(nameof(project));
var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
var buildIdsFile = Path.Combine(windowsDir, "buildIds.txt");
var buildIds = Enumerable.Empty<int>();
if (File.Exists(buildIdsFile))
{
var buildIdsContent = File.ReadAllLines(buildIdsFile);
try
{
buildIds = buildIdsContent.Select(x => int.Parse(x));
}
catch (FormatException e)
{
var content = string.Join(Environment.NewLine, buildIdsContent);
throw new InvalidOperationException(
$"Malformed {buildIdsFile}: {content}", e);
}
}
var builds = new List<Build>();
foreach (var buildId in buildIds)
{
var build = vsts.GetBuild(account, project, buildId);
builds.Add(build);
}
return builds
.OrderByDescending(x => x?.BuildNumber == null ? 0 :
x.BuildNumber.Length)
.FirstOrDefault();
}
}
|
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using VSORestAPI;
using System.IO;
public class TestVsts : IVsts
{
public List<Build> Builds;
public int NumberOfQueries;
public TestVsts()
{
Builds = new List<Build>();
NumberOfQueries = 0;
}
public void AddBuild(int buildId, string buildNumber)
{
Builds.Add(new Build { Id = buildId, BuildNumber = buildNumber
});
}
public Build GetBuild(string account, string project, int buildId)
{
NumberOfQueries++;
return Builds.SingleOrDefault(x => x.Id == buildId);
}
}
[TestClass]
public class UnitTests
{
private TestVsts vsts;
[TestInitialize]
public void Initialize()
{
vsts = new TestVsts();
vsts.AddBuild(1, "firstBuild");
vsts.AddBuild(2, "secondBuild");
vsts.AddBuild(3, "theHighestRankingBuild");
Program.vsts = vsts;
var desktopDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
File.WriteAllLines(Path.Combine(desktopDir, "buildIds.txt"), new[] { "1", "2" });
}
[TestMethod]
public void GetTopRankedBuild_ReturnsBestBuild_Test()
{
var build = Program.GetTopRankedBuild("blah", "blah");
Assert.AreEqual(2, build.Id);
Assert.AreEqual("secondBuild", build.BuildNumber);
}
[TestMethod]
public void GetTopRankedBuild_QueriesEachBuildInBuildIds_Test()
{
var build = Program.GetTopRankedBuild("blah", "blah");
Assert.AreEqual(2, vsts.NumberOfQueries);
}
}
|
Commentary On Iteration 1
The first iteration addresses a several failure scenarios
and adds a unit test suite.
Failure Scenario Improvements
First, the GetTopRankedBuild method validates that parameters
account and project
are non-null and non-empty. Next, the existence of buildIds.txt
is verified before trying to read it. If a FormatException
is thrown while parsing the content of buildIds.txt, it is
caught, and an InvalidOperation is thrown
that includes the path and content of buildsIds.txt in the exception
message. A possible NullReferenceException is
avoiding when ranking builds by assigning a rank of zero to builds that are
null or have a null BuildNumber property. Finally, Main
includes a conditional statement checking if the object returned by GetTopRankedBuild is null.
Unit Tests
The
following changes allow the program to be more easily unit tested:
1.
The VSTS service is exposed via the IVsts interface
2.
The VSTS service used by program is a
static field
During a test run, the TestInitialize function sets the IVsts service to an instance of TestVsts, so the service can be mocked. A tenet of
good testing is mocking (read: controlling) all parts of the program, except
for the portion (a.k.a. unit) you want to test.
Unit Test Shortcomings
There are still several issues
with this iteration of the unit tests:
·
The file system is not mocked, so the unit tests must create buildIds.txt
on the desktop of the test machine. Any data retrieval outside main memory
should be viewed as an external service
·
The program relies on global state, which is a bad programming
practice. Each unit test must set global memory, and then possibly reset it upon
completion
·
A change to the IVsts interface
requires a change to the testing implementation (TestVsts),
in addition to the actual implementation (HttpVsts),
even though an addition to the interface should not affect any tests
·
The testing implementation (TestVsts)
itself is code to write and maintain, and may can contain bugs
·
If there are many interfaces, writing test implementations of all
the program’s interface is burdensome work
o Lots of
boilerplate code may be required. For example, to test that an interface method
is called with a set of parameters, the testing implementation must record each
time it is called and its parameter values
·
The current unit tests only test the case where the program
behaves as expected, not when it fails
The program and its tests could
be improved if we:
·
Introduce an additional interface that exposes the file system as
a service
·
Introduce a mocking framework to avoid writing test
implementations of service interfaces
o In this
example, the Moq framework will be
used
Iteration 2
Program (Iteration 2)
|
Unit Tests
(Iteration 2)
|
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using VSORestAPI;
public interface IVsts
{
Build GetBuild(string account, string project, int buildId);
}
public class HttpVsts : IVsts
{
public Build GetBuild(string account, string project, int buildId)
{
return BuildAPI.GetBuild(account, project, buildId);
}
}
public interface IFileSystem
{
string[] ReadAllLines(string path);
bool FileExists(string path);
}
public class FileSytem : IFileSystem
{
public string[] ReadAllLines(string path)
{
return File.ReadAllLines(path);
}
public bool FileExists(string path)
{
return File.Exists(path);
}
}
public class Program
{
internal static IVsts vsts;
internal static IFileSystem fileSystem;
private static void CreateServices()
{
vsts = new HttpVsts();
fileSystem = new FileSytem();
}
public static void Main(string[] args)
{
CreateServices();
var account = args[0];
var project = args[1];
var bestBuild = GetTopRankedBuild(account, project);
Console.WriteLine($"The top ranked build is {bestBuild.BuildNumber}");
}
/// <summary>
/// Retrieves the top ranked build.
/// </summary>
/// <param name="account">the VSTS account the builds were
launched on</param>
/// <param name="project">the VSTS project the builds were
launched on</param>
/// <returns>the top ranked build, or null if there are no builds</returns>
internal static Build GetTopRankedBuild(string account, string project)
{
if (string.IsNullOrWhiteSpace(account)) throw new ArgumentException(nameof(account));
if (string.IsNullOrWhiteSpace(project)) throw new ArgumentException(nameof(project));
var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
var buildIdsFile = Path.Combine(windowsDir, "buildIds.txt");
var buildIds = Enumerable.Empty<int>();
if (fileSystem.FileExists(buildIdsFile))
{
var buildIdsContent = fileSystem.ReadAllLines(buildIdsFile);
try
{
buildIds = buildIdsContent.Select(x => int.Parse(x));
}
catch (FormatException e)
{
var content = string.Join(Environment.NewLine, buildIdsContent);
throw new InvalidOperationException(
$"Malformed {buildIdsFile}: {content}", e);
}
}
var builds = new List<Build>();
foreach (var buildId in buildIds)
{
var build = vsts.GetBuild(account, project, buildId);
builds.Add(build);
}
return builds
.OrderByDescending(x => x?.BuildNumber == null ? 0 :
x.BuildNumber.Length)
.FirstOrDefault();
}
}
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Sample2;
using VSORestAPI;
[TestClass]
public class UnitTests
{
Mock<IVsts> vsts;
Mock<IFileSystem> fileSystem;
[TestInitialize]
public void Initialize()
{
vsts = new Mock<IVsts>();
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1))
.Returns(new Build() { Id = 1, BuildNumber = "firstBuild" });
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 2))
.Returns(new Build() { Id = 2, BuildNumber = "secondBuild" });
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 3))
.Returns(new Build() { Id = 3, BuildNumber = "theHighestRankingBuild" });
Program.vsts = vsts.Object;
fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(x => x.ReadAllLines(It.IsAny<string>())).Returns(new[] { "1", "2" });
fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true);
}
[TestMethod]
public void GetTopRankedBuild_ReturnsBestBuild_Test()
{
var build = Program.GetTopRankedBuild("blah", "blah");
Assert.AreEqual(2, build.Id);
Assert.AreEqual("secondBuild", build.BuildNumber);
}
[TestMethod]
public void GetTopRankedBuild_QueriesEachBuildInBuildIds_Test()
{
var build = Program.GetTopRankedBuild("blah", "blah");
vsts.Verify(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1), Times.Once);
vsts.Verify(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 2), Times.Once);
}
}
|
|
|
Commentary On Iteration 2
The file system is now mocked, so the unit tests no longer must
create buildIds.txt on the Desktop of the test machine. Instead, the Moq
framework mocks the IFileSystem interface to
fake the existence of buildIds.txt and its content. The TestVsts class is removed and is replaced by a Moq
implementation.
The following two lines of code is an example of
how mock objects are controlled using Moq:
vsts = new Mock<IVsts>();
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1)).Returns(new Build() { Id = 1,
BuildNumber = "firstBuild" });
This is telling Moq that any call to IVsts.GetBuild where the build id is 1 (the third parameter)
should return a Build object whose id is 1
and BuildNumber is “firstBuild”. The IFileSystem is mocked the same way.
Similiarly, the following line
verifies that IVsts.GetBuild was called one
time when a build id of 1 was passed to it:
vsts.Verify(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1), Times.Once);
This functionality completely
replaces functionality that was implemented in TestVsts.
Pretty nice, huh?
An issue with this iteration of
the program is the unit tests must still set the global state of the program to
run the tests.
Iteration 3
Program (Iteration 3)
|
Unit Tests
(Iteration 3)
|
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using VSORestAPI;
public interface IVsts
{
Build GetBuild(string account, string project, int buildId);
}
public class HttpVsts : IVsts
{
public Build GetBuild(string account, string project, int buildId)
{
return BuildAPI.GetBuild(account, project, buildId);
}
}
public interface IFileSystem
{
string[] ReadAllLines(string path);
bool FileExists(string path);
}
public class FileSytem : IFileSystem
{
public string[] ReadAllLines(string path)
{
return File.ReadAllLines(path);
}
public bool FileExists(string path)
{
return File.Exists(path);
}
}
public class ServiceProvider
{
public IVsts vsts = new HttpVsts();
public IFileSystem fileSystem = new FileSytem();
}
public class Program
{
public static void Main(string[] args)
{
var account = args[0];
var project = args[1];
var bestBuild = GetTopRankedBuild(new ServiceProvider(), account,
project);
Console.WriteLine($"The top ranked build is {bestBuild.BuildNumber}");
}
/// <summary>
/// Retrieves the top ranked build.
/// </summary>
/// <param name="sp">the service provider</param>
/// <param name="account">the VSTS account the builds were
launched on</param>
/// <param name="project">the VSTS project the builds were
launched on</param>
/// <returns>the top ranked build, or null if there are no builds</returns>
internal static Build GetTopRankedBuild(ServiceProvider sp, string account, string project)
{
if (string.IsNullOrWhiteSpace(account)) throw new ArgumentException(nameof(account));
if (string.IsNullOrWhiteSpace(project)) throw new ArgumentException(nameof(project));
var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
var buildIdsFile = Path.Combine(windowsDir, "buildIds.txt");
var buildIds = Enumerable.Empty<int>();
if (sp.fileSystem.FileExists(buildIdsFile))
{
var buildIdsContent = sp.fileSystem.ReadAllLines(buildIdsFile);
try
{
buildIds = buildIdsContent.Select(x => int.Parse(x));
}
catch (FormatException e)
{
var content = string.Join(Environment.NewLine, buildIdsContent);
throw new InvalidOperationException(
$"Malformed {buildIdsFile}: {content}", e);
}
}
var builds = new List<Build>();
foreach (var buildId in buildIds)
{
var build = sp.vsts.GetBuild(account, project, buildId);
builds.Add(build);
}
return builds
.OrderByDescending(x => x?.BuildNumber == null ? 0 :
x.BuildNumber.Length)
.FirstOrDefault();
}
}
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Sample3;
using VSORestAPI;
[TestClass]
public class UnitTests
{
ServiceProvider sp;
Mock<IVsts> vsts;
Mock<IFileSystem> fileSystem;
[TestInitialize]
public void CreateMockServiceProvider()
{
vsts = new Mock<IVsts>();
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1))
.Returns(new Build() { Id = 1, BuildNumber = "firstBuild" });
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 2))
.Returns(new Build() { Id = 2, BuildNumber = "secondBuild" });
vsts.Setup(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 3))
.Returns(new Build() { Id = 3, BuildNumber = "theHighestRankingBuild" });
fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(x => x.ReadAllLines(It.IsAny<string>())).Returns(new[] { "1", "2" });
fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true);
sp = new ServiceProvider()
{
vsts = vsts.Object,
fileSystem = fileSystem.Object,
};
}
[TestMethod]
public void GetTopRankedBuild_ReturnsBestBuild_Test()
{
var build = Program.GetTopRankedBuild(sp, "blah", "blah");
Assert.AreEqual(2, build.Id);
Assert.AreEqual("secondBuild", build.BuildNumber);
}
[TestMethod]
public void GetTopRankedBuild_QueriesEachBuildInBuildIds_Test()
{
var build = Program.GetTopRankedBuild(sp, "blah", "blah");
vsts.Verify(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 1), Times.Once);
vsts.Verify(x => x.GetBuild(It.IsAny<string>(), It.IsAny<string>(), 2), Times.Once);
}
}
|
Commentary On Iteration 3
The ServiceProvider class
is introduced as a container of services; it encapsulates the IVsts and IFileSystem services.
The GetTopRankedBuild is modified to accept an instance of ServiceProvider as its first parameter. The unit
tests were modified to populate a ServiceProvider with
mock IVsts and IFileSystem
services. This removes the use of static fields entirely, and creates a
scalable pattern for adding more services and passing services around the
program as it grows.
Future Improvments
·
Add unit tests for failure scenarios
·
Use a generic service provider
o
For example, var fileSystem =
serviceProvider.GetService<IFileSystem>()
Additional Reading
Programmers like to argue over
terminology when it comes to unit testing. I use the verb “mock” to mean
1. Controlling
the return values of services
2. Verifying
services are called with the correct parameters
For more reading, see Mocks Aren’t Stubs,
and a response to it, Mocks,
Stubs, and Fakes: it’s a continuum