Get up to 80 % extra points for free! More info:

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 the Equals() method and find out whether they are the same. We do not use instead of AreEqual() 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 to Fail(), 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 is null.
  • 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 named ThrowsExceptionAsync().

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:

Running unit tests in Visual Studio - Testing in C# .NET

We'll see results that look like this:

Test results in Visual Studio - Testing in C# .NET

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:

An error in C# .NET unit tests - Testing in C# .NET

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 as QuadraticEquation_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.


 

Previous article
Testing in C# .NET - Introduction to unit tests
All articles in this section
Testing in C# .NET
Skip article
(not recommended)
Testing in C# .NET - Selenium WebDriver syntax overview
Article has been written for you by David Capka Hartinger
Avatar
User rating:
3 votes
The author is a programmer, who likes web technologies and being the lead/chief article writer at ICT.social. He shares his knowledge with the community and is always looking to improve. He believes that anyone can do what they set their mind to.
Unicorn university David learned IT at the Unicorn University - a prestigious college providing education on IT and economics.
Activities