Lesson 3 - Testing in C# .NET - Finishing unit tests and best practices
In the previous lesson, Testing in C# .NET - Introduction to unit tests, we prepared a simple class and generated a test project with a reference to the application project. Today, we're going to cover our simple class with tests, mention available assert methods, and finish unit tests in C# .NET with the best practices overview.
Each method will always be marked with the [TestMethod]
attribute and will test one particular method from the Calculator
class, typically for several different inputs. If you're wondering why we mark
the methods with attributes, it allows us to create some auxiliary methods that
we can use in the test and which will not be considered as tests. This is
because Visual Studio runs the tests (the methods with the
[TestMethod]
annotation) automatically and prints their
results.
Let's add the following 5 methods to the CalculatorTests
class:
[TestMethod] public void Add() { Assert.AreEqual(2, calculator.Add(1, 1)); Assert.AreEqual(1.42, calculator.Add(3.14, -1.72), 0.001); Assert.AreEqual(2.0 / 3, calculator.Add(1.0 / 3, 1.0 / 3), 0.001); } [TestMethod] public void Subtract() { Assert.AreEqual(0, calculator.Subtract(1, 1)); Assert.AreEqual(4.86, calculator.Subtract(3.14, -1.72), 0.001); Assert.AreEqual(2.0 / 3, calculator.Subtract(1.0 / 3, -1.0 / 3), 0.001); } [TestMethod] public void Multiply() { Assert.AreEqual(2, calculator.Multiply(1, 2)); Assert.AreEqual(-5.4008, calculator.Multiply(3.14, -1.72), 0.001); Assert.AreEqual(0.111, calculator.Multiply(1.0 / 3, 1.0 / 3), 0.001); } [TestMethod] public void Divide() { Assert.AreEqual(2, calculator.Divide(4, 2)); Assert.AreEqual(-1.826, calculator.Divide(3.14, -1.72), 0.001); Assert.AreEqual(1, calculator.Divide(1.0 / 3, 1.0 / 3)); } [TestMethod] [ExpectedException(typeof(ArgumentException))] public void DivideException() { calculator.Divide(2, 0); }
We use static methods on the Assert
class to compare the output
of the method with the expected value. You will most likely use the
AreEqual()
method, which accepts the expected value as the first
parameter and the actual value as the second parameter. It's a good idea to
maintain this order, otherwise, you'll have the values swapped in the test
results. As you probably know, decimal numbers are stored in binary in computer
memory (obviously ) and this
causes some loss of their accuracy and also difficulties when comparing them.
Therefore, we have to provide the third parameter in this case, which is
delta, a positive tolerance, how much the expected and actual
value may vary for the test to be successful.
Note that we try various inputs. We do not only test the addition as 1 + 1 = 2, but we test the integer, decimal, and negative inputs separately and verify the results. In some cases, we might also be interested in the maximum value of the data types and similar borderline values.
The last test verifies whether the Divide()
method really throws
an exception when dividing by zero. As you can see, we don't have to bother with
the try
-catch
blocks, we just need to add the
[ExpectedException]
attribute above the method and specify which
exception type is expected there. If the exception doesn't occur, the test
fails. We can use additional assert methods to test multiple exception types,
see below.
Available assert methods
We should mention that the comparison take data types into account, i.e.
10L
(long
) is a different value than 10
(int
). Besides the AreEqual()
method, we can use many
more. Always try to use the most suitable method, it makes the error messages
clear when the test fails and, of course, it's easier to fix it.
AreNotEqual()
- We use it if we want to verify that 2 objects do not match. We won't mention the other methods with Not here.AreSame()
- Checks whether 2 references point to the same object (compares using==
).Equals()
- We use it when we want to verify 2 objects using theEquals()
method and find out whether they are the same. We do not use instead ofAreEqual()
to assert a value.Fail()
- Causes a test failure, we usually use it after a condition, and add optional parameters such as the error message and parameters.Inconclusive()
- Works similar toFail()
, it throws an exception indicating the inconclusiveness of the test.IsFalse()
- Verifies whether a given expression is false.IsInstanceOfType()
- Verifies whether an object is an instance of a given type.IsNull()
- Verifies that a value isnull
.IsTrue()
- Verifies whether an expression is true.ReplaceNullChars()
- Replaces the null characters ("\0") with "\\0". We'll use this especially for diagnostic messages for strings containing these characters.ThrowsException()
- Runs a given delegate and verifies it throws an exception passed as a generic argument. The method also has an asynchronous version namedThrowsExceptionAsync()
.
Don't get confused by the ReferenceEquals()
method, which is not
part of the tests, but is a standard method on all classes.
Running the tests
We'll run the tests from the menu Test -> Run -> All Tests:

We'll see results that look like this:

Let's try to make a mistake in the calculator, for example, let's comment out
the line throwing of an exception while dividing by zero and always return the
value of 1
instead:
public double Divide(double a, double b) { // if (b == 0) // throw new ArgumentException("Cannot divide by zero!"); return 1; }
And let's run our tests again:

We can see that the bug is caught and we are informed about it. Both the division and the exception tests have not passed. We can fix the code to its original state.
Best practices
We've already mentioned some good practices in the previous lessons. Since this is all for C# .NET unit tests, let's finally list what common mistakes to avoid in order to achieve high quality results.
- We test the specification, not the code. We never write tests according to the code of a method, but we think about what the purpose of the method really is and what kinds of inputs could be passed to it.
- We do test common libraries, not the specific application logic. If the logic is important and common, it should be separated into an independent library, and the library should be covered with tests.
- Each test should be completely independent of the other tests. The scenario should pass even if we'd shuffle its methods, no method should leave behind any changes (in files, in the database, etc.), that would affect other methods. In order to achieve this behavior, we often prepare the environment for the individual methods in the initialize method and, if necessary, afterwards, we perform cleaning in the clean up method. The same applies to whole tests as well.
- Each test should always have the same result, regardless of when we run it. Beware of testing random output generators and date and time handling methods.
- Do not perform duplicate assertions, if some input is tested by another test, do not re-check it (DRY).
- Each scenario tests only one unit (class). Your software should be designed so that it's divided into smaller classes that have minimal dependence on others and therefore can be easily and independently tested (the high cohesion and low coupling design patterns).
- If tests require external services, we should mock them (see next lessons). By doing so, we create "fake" services with the same interface, which usually just provide test data. By using real services, we'd break the independence of tests as they would start to influence each other. A less elegant solution is to set up the original service state at the start and restore it at the end.
- As everywhere else, avoid misleading test
names (like
calculate()
,exception()
and so on). Programmers often name tests with more words to make it easy to identify what they do. Normally, we shouldn't do this because each method does one thing only, but sometimes it makes sense to name the methods clumsy, for example asQuadraticEquation_NegativeCoefficients_Exception()
, because a test often tests multiple inputs. Ideally, the name of the test should contain the name of the method being tested. In naming the tests, you should be consistent. Don't be afraid of comments. - Tests should be fast because in practice we usually test all the parts of our application with different types of tests, and their duration can easily accumulate in an unpleasant pause.
Your first unit tests don't have to be perfect, it's enough to just briefly test the most important parts. You'll see they'll start to fail sooner or later and reveal implementation errors. The bigger the application, the higher test code coverage we should try to achieve.
In the next lesson, Testing in C# .NET - Selenium WebDriver syntax overview, we'll take a look at the acceptance tests.