Now that we have learned a bit about classes, we’re going to use the same feature to support unit testing. Unit testing is a concept that will become part of just about everything you do in future programming-focused courses, so we want to make sure that you understand the idea and begin to make use of it in all of your work.
The notion of unit testing is straightforward in principle. When you write a program in general, the program comprises what are properly known as units of development. Each language has its own definition of what units are but most modern programming languages view the class concept as the core unit of testing. Once we have a class, we can test it and all of the parts associated with it, especially its methods.
A key notion of testing is the ability to make a logical assertion about something that generally must hold true if the test is to pass.
Assertions are not a standard language feature in C#. Instead, there are a number of classes that provide functions for assertion handling. In the framework we are using for unit testing (NUnit), a class named Assert supports assertion testing.
In our tests, we make use of an assertion method, Assert.IsTrue() to determine whether an assertion is successful. If the variable or expression passed to this method is false, the assertion fails.
Here are some examples of assertions:
There are many available assertion methods. In our tests, we use Assert.IsTrue(), which works for everything we want to test. Other assertion methods do their magic rather similarly, because every assertion method ultimately must determine whether what is being tested is true or false.
Besides assertions, a building block of testing (in C# and beyond) comes in the form of attributes. Attributes are an additional piece of information that can be attached to classes, variables, and methods in C#. There are two attributes of interest to us:
Without these annotations, classes and methods will not be used for testing purposes. This allows a class to have some methods that are used for testing while other methods are ignored.
In the remainder of this section, we’re going to take a look at the strategy for testing the Rational class. In general, your goal is to ensure that the entire class is tested. It is easier said than done. In later courses (Software Engineering) you would learn about strategies for coverage testing.
Our strategy will be as follows:
Let’s get started.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | [Test()]
public void ConstructorTest()
{
Rational r = new Rational(3, 5);
Assert.IsTrue(r.GetNumerator() == 3);
Assert.IsTrue(r.GetDenominator() == 5);
r = new Rational(3, -5);
Assert.IsTrue(r.GetNumerator() == -3);
Assert.IsTrue(r.GetDenominator() == 5);
r = new Rational(6, 10);
Assert.IsTrue(r.GetNumerator() == 3);
Assert.IsTrue(r.GetDenominator() == 5);
r = new Rational(125, 1);
Assert.IsTrue(r.GetNumerator() == 125);
Assert.IsTrue(r.GetDenominator() == 1);
}
|
Testing the constructor is fairly straightforward. We essentially test three basic cases:
Test whether a basic rational number can be constructed. In the above, we test for 3/5, 3/-5, 6/10, and 125. Per the implementation of the Rational class (how we defined it), these should result in fractions with numerators of 3, -3, 3, and 12; and denominators of 5, 5, 5, and 1, respectively.
As you can observe from the code, we perform basic assertion testing to ensure that the numerators and denominators are what we expect. For example:
Assert.IsTrue(r.GetNumerator() == 3)
Tests whether the newly minted rational number, Rational(3, 5), actually has the expected numerator of 3.
If we are able to get through the entire code of the ConstructorTest() method, our constructor test is a success. Otherwise, it is a failure.
We’ll look at how to actually run our tests in a bit but let’s continue taking a look at how the rest of our testing is done.
1 2 3 4 5 6 7 8 9 | [Test()]
public void BasicComparisonTests() {
Rational r1 = new Rational(-3, 6);
Rational r2 = new Rational(2, 4);
Rational r3 = new Rational(1, 2);
Assert.IsTrue(r1.CompareTo(r2) < 0);
Assert.IsTrue(r2.CompareTo(r1) > 0);
Assert.IsTrue(r2.CompareTo(r3) == 0);
}
|
It is pretty well established by now that the ability to compare is of fundamental importance whenever we are talking about data. Everything we do, especially when it comes to searching (finding a value) and sorting (putting values in order) depends on comparison.
In this test, we construct a few Rational instances (r1, r2, and r3) and perform at least one test for each of the essential operators (>, <, and =). Recall from our earlier discussion of the Rational class that we return < 0 when one Rational is less than another. We return > 0 for greater than, and == 0 for equal to.
If any one of these comparisons fails, this means that we cannot rely on the ability to compare Rational numbers. This will likely prevent other tests from working, such as the arithmetic tests, which rely on the ability to test whether a computed result matches an expected result (e.g. 1/4 + 2/4 == 3/4).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | [Test()]
public void BasicArithmeticTests() {
Rational r, r1, r2;
r1 = new Rational(47, 64);
r2 = new Rational(-11, 64);
r = r1.Add(r2);
Assert.IsTrue(r.CompareTo(new Rational(36, 64)) == 0);
r = r1.Subtract(r2);
Assert.IsTrue(r.CompareTo(new Rational(58, 64)) == 0);
r = r1.Multiply(r2);
Assert.IsTrue(r.CompareTo(new Rational(47 * -11, 64 * 64)) == 0);
r = r1.Divide(r2);
Assert.IsTrue(r.CompareTo(new Rational(47, -11)) == 0);
r = r1.Reciprocal();
Assert.IsTrue(r.CompareTo(new Rational(64, 47)) == 0);
r = r1.Negate();
Assert.IsTrue(r.CompareTo(new Rational(-47, 64)) == 0);
}
|
Testing of arithmetic is a fairly straightforward idea. For all of these tests, we create a couple of rational numbers (47/64 and -11/64) and then call the various methods to perform addition, subtraction, multiplication, division, reciprocal, and negation.
The key to testing arithmetic successfully in the case of a Rational number is to know know what the result should be. As a concrete example, the result of adding these two rational numbers should be 36/64. So the testing strategy is to use the Add() method to add the two rational numbers and then test whether the result of the addition is equal to the known answer of 36/64.
As you can observe by looking at the code, the magic occurs by checking whether the computed result matches the constructed result:
Assert.IsTrue(r.CompareTo(new Rational(36, 64)) == 0);
Because we have separately tested the constructor and comparison methods, we can assume that it is ok to rely upon comparison methods as part of this arithmetic test.
And it is in this example where we begin to see the art of testing. You can write tests that assume that other tests of features you are using have already passed. In the event that your assumption is wrong, you’d be able to know that this is the case, because all of the tests you assumed to pass would not have passed.
Again, to be clear, the arithmetic tests we have done here assume that we can rely on the constructor and the comparison operation to determine equality of two rational numbers. It is entirely possible that this is not true, so we’ll be able to determine this when examining the test output (we’d see that not only the arithmetic test fails but possibly the constructor and/or comparison tests as well).
The remaining tests are fairly straightforward. We’ll more or less present them as is with minimal explanation as they are in many ways variations on the theme.
1 2 3 4 5 6 7 8 9 10 11 | [Test()]
public void ConversionTests() {
Rational r1 = new Rational(3, 6);
Rational r2 = new Rational(-3, 6);
Assert.IsTrue(r1.ToDecimal() == 0.5m);
Assert.IsTrue(r2.ToDecimal() == -0.5m);
Assert.IsTrue(r1.ToDouble() == 0.5);
Assert.IsTrue(r2.ToDouble() == -0.5);
}
|
In this test, we want to make sure that Rational objects can be converted to floating point and decimal types (the built-in types of the C# language).
For example, Rational(3/6) is 1/2, which is 0.5 (both in its floating-point and decimal representations.
1 2 3 4 5 6 7 8 9 10 11 12 | [Test()]
public void ParseTest()
{
Rational r;
r = Rational.Parse("-12/30");
Assert.IsTrue(r.CompareTo(new Rational(-12, 30)) == 0);
r = Rational.Parse("123");
Assert.IsTrue(r.CompareTo(new Rational(123, 1)) == 0);
r = Rational.Parse("1.125");
Assert.IsTrue(r.CompareTo(new Rational(9, 8)) == 0);
Assert.IsTrue(r.ToString().Equals("9/8"));
}
|
The parsing test tests whether we can convert the string representation of a rational number into an actual (reduced) rational number. We test three general cases:
To run the unit tests in your program (or library), use the nunit-console program that ships with Mono on the generated executable (.exe) or (.dll). You can pass any .dll or .exe file to nunit-console, and nunit-console will examine it for the presents of unit tests and attempt to run all of them.
$ nunit-console -labels Rational.dll
NUnit version 2.5.10.0
Copyright (C) 2002-2010 Charlie Poole.
Copyright (C) 2002-2004 James W. Newkirk, Michael C. Two, Alexei A. Vorontsov.
Copyright (C) 2000-2002 Philip Craig.
All Rights Reserved.
Runtime Environment -
OS Version: Unix 3.0.0.16
CLR Version: 4.0.30319.1 ( 2.10.5 (Debian 2.10.5-1) )
ProcessModel: Default DomainUsage: Single
Execution Runtime: Default
***** Music.RationalTests.BasicArithmeticTests
***** Music.RationalTests.BasicComparisonTests
***** Music.RationalTests.ConstructorTest
***** Music.RationalTests.ConversionTests
***** Music.RationalTests.ParseTest
Tests run: 5, Errors: 0, Failures: 0, Inconclusive: 0, Time: 0.03596 seconds
Not run: 0, Invalid: 0, Ignored: 0, Skipped: 0
As you can see in the above output, all of the RationalTests examples are being executed. We used the -labels option to give more verbose output, which has the effect of printing the names of the tests that were completed.
You can also observe that all of the tests have passed!