Book Image

Practical Test-Driven Development using C# 7

By : John Callaway, Clayton Hunt
Book Image

Practical Test-Driven Development using C# 7

By: John Callaway, Clayton Hunt

Overview of this book

Test-Driven Development (TDD) is a methodology that helps you to write as little as code as possible to satisfy software requirements, and ensures that what you've written does what it's supposed to do. If you're looking for a practical resource on Test-Driven Development this is the book for you. You've found a practical end-to-end guide that will help you implement Test-Driven Techniques for your software development projects. You will learn from industry standard patterns and practices, and shift from a conventional approach to a modern and efficient software testing approach in C# and JavaScript. This book starts with the basics of TDD and the components of a simple unit test. Then we look at setting up the testing framework so that you can easily run your tests in your development environment. You will then see the importance of defining and testing boundaries, abstracting away third-party code (including the .NET Framework), and working with different types of test double such as spies, mocks, and fakes. Moving on, you will learn how to think like a TDD developer when it comes to application development. Next, you'll focus on writing tests for new/changing requirements and covering newly discovered bugs, along with how to test JavaScript applications and perform integration testing. You’ll also learn how to identify code that is inherently un-testable, and identify some of the major problems with legacy applications that weren’t written with testability in mind. By the end of the book, you’ll have all the TDD skills you'll need and you’ll be able to re-enter the world as a TDD expert!
Table of Contents (21 chapters)
Title Page
Packt Upsell
Foreword
Contributors
Preface
4
What to Know Before Getting Started
Index

Different types of test


Over the course of this book, we will be leaning towards a particular style of testing, but it is important to understand the terminology that others will use so that you can relate when they speak about a certain type of test. 

Unit tests 

Let's jump right in with the most misused and least understood test type. In Kent Beck's book, Test-Driven Development by Example, he defines a unit test as simply a test that runs in isolation from the other tests. All that means is that for a test to be a unit test, all that has to happen is that the test must not be affected by the side-effects of the other tests. Some common misconceptions are that a unit test must not hit the database, or that it must not use code outside the method or function being tested. These simply aren't true. We tend to draw the line in our testing at third-party interactions. Any time that your tests will be accessing code that is outside the application you are writing, you should abstract that interaction. We do this for maximum flexibility in the design of the test, not because it wouldn't be a unit test. It is the opinion of some that unit tests are the only tests that should ever be written. This is based on the original definition, and not on the common usage of the term. 

Acceptance tests 

Tests that are directly affected by business requirements, such as those suggested in BDD, are generally referred to as acceptance tests. These tests are at the outermost limit of the application and exercise a large swathe of your code. To reduce the coupling of tests and production code, you could write this style of test almost exclusively. Our opinion is, if a result cannot be observed outside the application, then it is not valuable as a test. 

Integration tests 

Integration tests are those that integrate with an external system. For instance, a test that interacts with a database would be considered an integration test. The external system doesn't have to be a third-party product; however, sometimes, the external system is just an imported library that was developed independently from the application you are working on but is still considered in-house software. Another example that most don't consider is interactions with the system or language framework. You could consider any test that uses the functions of C#'s DateTime object to be an integration test. 

End to end tests 

These tests validate the entire configuration and usage of your application. Starting from the user interface, an end to end test will programmatically click a button or fill out a form. The UI will call into the business logic of the application, executing all the way down to the data source for the application. These tests serve the purpose of ensuring that all external systems are configured and operating correctly. 

Quantity of each test type 

Many developers ask the question: How many of each type of test should be used? Every test should be a unit test, as per Kent Beck's definition. We will cover variations on testing later that will have some impact on specific quantities of each type; but, generally, you might expect an application to have very few end to end tests, slightly more integration tests, and to consist mostly of acceptance tests. 

Parts of a unit test

The simplest way to get started and ensure that you have human-readable code is to structure your tests using Arrange, Act, and Assert.

Arrange

Also known as the context of a unit test, Arrange includes anything that exists as a prerequisite of the test. This includes everything from parameter values, stored in variables to improve readability, all the way to configuring values in a mock database to be injected into your application when the test is run.

Note

For more information on Mocking, see Chapter 3Setting Up the JavaScript Environment, the Abstract Third Party Software and Test Double Types sections.

Act

An action, as part of a unit test, is simply the piece of production code that is being tested. Usually, this is a single method or function in your code. Each test should have only a single action. Having more than one action will lead to messier tests and less certainty about where the code should change to make the test pass.

Assert

The result, or assertion (the expected result), is exactly what it sounds like. If you expect that the method being tested will return a 3, then you write an assertion that validates that expectation. The Single Assert Rule states that there should be only one assertion made per test. This does not mean that you can only assert once; instead, it means that your assertions should only confirm one logical expectation. As a quick example, you might have a method that returns a list of items after applying a filter. After setting up the test context, calling the method will result in a list of only one item, and that item will match the filter that we have defined. In this case, you will have a programmatic assert for the count of items in the list and one programmatic assert for the filter criterion we are testing. 

Requirements 

While this book is not about business analysis or requirement generation, requirements will have a huge impact on your ability to effectively test-drive an application. We will be providing requirements for this book in a format that lends itself very well to high-quality tests. We will also cover some scenarios where the requirements are less than optimal, but for most of this book the requirements have been labored over to ensure a high-quality definition of the systems we are testing. 

Why are they important? 

We firmly believe that quality requirements are essential to a well-developed solution. The requirements inform the tests and the tests shape the code. This axiom means that with poor requirements, the application will result in a lower quality architecture and overall design. With haphazard requirements, the resulting tests and application will be chaotic and poorly factored. On the bright side, even poorly thought out or written requirements aren't the death knoll for your code. It is our responsibility, as professional software developers, to correct bad requirements. It is our task to ask questions that will lead to better requirements.  

User stories 

User stories are commonly used in Agile software development for requirement definitions. The format for a user story is fairly simple and consists of three parts: Role, Request, and Reason.  

As a <Role> 
I want <Request> 
So that <Reason> 
Role 

The role of the user story can provide a lot of information. When specifying the role, we have the ability to imply the capabilities of the user. Can the user access certain functionalities, or are they physically impaired in such a way that requires an alternate form of interaction with the system? We can also communicate the user's mindset. Having a new user could have an impact on the design of the user interface, in contrast to what an experienced user might expect. The role can be a generic user, a specific role, a persona, or a specific user. 

Generic users are probably the most used and, at the same time, the least useful. Having a story that provides no insight into the user limits our decision making for this story by not restricting our context. If possible, ask your business analyst or product owner for a more specific definition of who the requirement is for. 

Defining a specific role, such as Admin, User, or Guest, can be very helpful. Specific roles provide user capability information. With a specific role, we can determine if a user should even be allowed into the section of the application we are defining functionality for. It is possible that a user story will cause the modification of a user's rights within the system, simply because we specified a role instead of a generic user. 

Using a persona is the most telling of the wide-reaching role types. A persona is a full definition of an imaginary user. It includes a name, any important physical attributes, preferences, familiarity with the subject of the application, familiarity with computers, and anything else that might have an impact on the imaginary user's interactions with the software. By having all this information, we can start to roleplay the user's actions within the system. We can start to make assumptions or decisions about how that user would approach or feel about a suggested feature and we can design the user interface with that user in mind. 

Request 

The request portion of the user story is fairly simple. We should have a single feature or a small addition to functionality that is being requested. Generally, the request is too large if it includes any joining words, such as and or or

Reason 

The reason is where the business need is stated. This is the opportunity to explain how the feature will add value to the company. By connecting the reason to the role, we can enhance the impact of the feature's usefulness. 

A complete user story might look like the following:

As a Conference Speaker 
I want to search for nearby conferences by open submission date 
So that I may plan the submission of my talks 

Gherkin 

Gherkin is a style of requirements definitions that is often used for acceptance criteria. We can turn these requirements directly into code, and QA can turn them directly into test cases. The Gherkin format is generally associated with BDD, and it is used in Dan North's original article on the subject.  

The Gherkin format is just as simple as the user story format. It consists of three parts: Given, When, and Then

Given <Context> 
And Given <More Context> 
When <Action> 
Then <Result> 
And Then <More Results> 
Givens 

Because the Gherkin format is fairly simple, givens are broken out to one per contextual criterion. As part of specifying the context, we want to see any and all preconditions of this scenario. Is the user logged in? Does the user have any special rights? Does this scenario require any settings to be put into force before execution? Has the user provided any input on this scenario? One more thing to consider is that there should only be a small number of givens.

The more givens that are present in a scenario, the more likely it is that the scenario is too big or that the givens can somehow be logically grouped to reduce the count. 

Note

When we start writing our tests, a Given is analogous to the Arrange section of a test.  

When 

The when is the action taken by the user. There should be one action and only one action. This action will depend on the context defined by the Given and output the result expected by the Then. In our applications, this is equivalent to a function or method call. 

Note

When we start writing our tests, a When is analogous to the Act section of a test.

Then 

Thens equate to the output of the action. Thens describe what can be verified and tested from the output of a method or function, not only by developers but also by QA.  Just like with the Givens, we want our Thens to be singular in their expectation. Also like Givens, if we find too many Thens, it is either a sign that this scenario is getting too big, or that we are over-specifying our expectations. 

Note

When we start writing our tests, a Then is analogous to the Assert section of a test.

Complete acceptance criteria based on the user story presented earlier might look like the following:

Given I am a conference speaker 
And Given a search radius of 25 miles 
And Given an open submission start date 
And Given an open submission end date 
When I search for conferences 
Then I receive only conferences within 25 miles of my location 
And Then I receive only conferences that are open for submission within the specified date range 

Just like in life, not everything in this book is going to be perfect. Do you see anything wrong with the preceding acceptance criteria? Go on and take a few minutes to examine it; we'll wait.  

If you've given up, we'll tell you. The above acceptance criteria are just too long. There are too many Givens and too many Thens. How did this happen? How could we have created such a mistake? When we wrote the user story, we accidentally included too much information for the reason that we specified. If you go back and look at the user story, you will see that we threw nearby in the request. Adding nearby seemed harmless; it even seemed more correct. I, as the user, wasn't so interested in traveling too far for my speaking engagements.  

When you start to see user stories or acceptance criteria getting out of hand like this, it is your responsibility to speak with the business analyst or product owner and work with them to reduce the scope of the requirements. In this case, we can extract two user stories and several acceptance criteria. 

Here is a full example of the requirements we have been examining:

As a conference speaker 
I want to search for nearby conferences 
So that I may plan the submission of my talks 
Given I am a conference speaker 
And Given search radius of five miles 
When I search for conferences 
Then I receive only conferences within five miles of my location 
Given I am a conference speaker 
And Given search radius of 10 miles 
When I search for conferences 
Then I receive only conferences within 10 miles of my location 
Given I am a conference speaker 
And Given search radius of 25 miles 
When I search for conferences 
Then I receive only conferences within 25 miles of my location 

As a conference speaker 
I want to search for conferences by open submission date 
So that I may plan the submission of my talks 
Given I am a conference speaker 
And Given open submission start and end dates 
When I search for conferences 
Then I receive only conferences that are open for submission within the specified date range 
Given I am a conference speaker 
And Given an open submission start date 
And Given an empty open submission end date 
When I search for conferences 
Then an INVALID_DATE_RANGE error occurs for open submission date 
Given I am a conference speaker 
And Given an empty open submission start date 
And Given an open submission end date 
When I search for conferences 
Then an INVALID_DATE_RANGE error occurs for open submission date 

One thing that we have not discussed is the approach to the content of the user stories and acceptance criteria. It is our belief that requirements should be as agnostic about the user interface and data storage mechanism as possible. For that reason, in the requirement examples, you'll notice that there is no reference to any kind of buttons, tables, modals/popups, clicking, or typing. For all we know, this application is running in a Virtual Reality Helmet with a Natural User Interface. Then again, it could be running as a RESTful web API, or maybe a phone application. The requirements should specify the system interactions, not the deployment environment. 

In software development, it is everyone's responsibility to ensure high-quality requirements. If you find the requirements you have received to be too large, vague, user interface-dependent, or just unhelpful, it is your responsibility to work with your business analyst or product owner to make the requirements better and ready for development and QA. 

Our first tests in C#

Have you ever created a new MVC project in Visual Studio? Have you noticed the checkbox towards the bottom of the dialog box? Have you ever selected, Create Unit Test Project? The tests created with this Unit Test Project are largely of little use. They do little more than validate that the default MVC controllers return the proper type. This is perhaps one step beyond, ItExists. Let's look at the first set of tests created for us:

using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SampleApplication.Controllers;

namespace SampleApplication.Tests.Controllers
{
  [TestClass]
  public class HomeControllerTest
  {
    [TestMethod]
    public void Index()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.Index() as ViewResult;

      // Assert
      Assert.IsNotNull(result);
    }

    [TestMethod]
    public void About()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.About() as ViewResult;

      // Assert
      Assert.AreEqual("Your application…", result.ViewBag.Message);
    }

    [TestMethod]
    public void Contact()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.Contact() as ViewResult;

      // Assert
      Assert.IsNotNull(result);
    }
  }
}

Here, we can see the basics of a test class, and the test cases contained within. Out of the box, Visual Studio ships with MSTest, which is what we can see here. The test class must be decorated with the [TestClass] attribute. Individual tests must likewise also be decorated with the [TestMethod] attribute. This allows the test runner to determine which tests to execute. We'll cover these attributes and more in future chapters. Other testing frameworks use similar approaches that we'll discuss later, as well.

For now, we can see that the HomeController is being tested. Each of the public methods has a single test, for which you may want to create additional tests and/or extract tests to separate files in the future. Later we'll be covering options and best practices to help you arrange your files in a much more manageable fashion. All of this should be part of your refactor step in your red, green, refactor cycle.

Growing the application with tests

Perhaps you want to accept a parameter for one of your endpoints. Maybe you will take a visitor's name to display a friendly greeting. Let's take a look at how we might make that happen:

[TestMethod]
public void ItTakesOptionalName()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.About("") as ViewResult;

  // Assert
  Assert.AreEqual("Your application description page.", result.ViewBag.Message);
}

We start by creating a test to allow for the About method to accept an optional string parameter. We're starting with the idea that the parameter is optional since we don't want to break any existing tests. Let's see the modified method:

public ActionResult About(string name = default(string))
{
  ViewBag.Message = "Your application description page.";
  return View();
}    

Now, let's use the name parameter and just append it to our ViewBag.Message. Wait, not the controller. We need a new test first:

[TestMethod]
public void ItReturnsNameInMessage()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.About("Fred") as ViewResult;

  // Assert
  Assert.AreEqual("Your application description page.Fred", result.ViewBag.Message); 
}

And now we'll make this test pass:

public ActionResult About(string name = default(string))
{
  ViewBag.Message = $"Your application description page.{name}";
  return View();
}

Our first tests in JavaScript

To get the ball rolling in JavaScript, we are going to write a Simple Calculator class. Our calculator only has the requirement to add or subtract a single set of numbers. Much of the code you write in TDD will start very simply, just like this example:

import { expect } from 'chai'

class SimpleCalc {
  add(a, b) {
    return a + b;
  }

  subtract(a, b) {
    return a - b;
  }
}

describe('Simple Calculator', () => {
  "use strict";

  it('exists', () => {
    // arrange
    // act
    // assert
    expect(SimpleCalc).to.exist;
  });

  describe('add function', () => {
    it('exists', () => {
      // arrange
      let calc;

      // act
      calc = new SimpleCalc();

      // assert
      expect(calc.add).to.exist;
    });

    it('adds two numbers', () => {
      // arrange
      let calc = new SimpleCalc();

      // act
      let result = calc.add(1, 2);

      // assert
      expect(result).to.equal(3);
    });
  });

  describe('subtract function', () => {
    it('exists', () => {
      // arrange
      let calc;

      // act
      calc = new SimpleCalc();

      // assert
      expect(calc.subtract).to.exist;
    });

    it('subtracts two numbers', () => {
      // arrange
      let calc = new SimpleCalc();

      // act
      let result = calc.subtract(3, 2);

      // assert
      expect(result).to.equal(1);
    });
  });
});

If the preceding code doesn't make sense right now, don't worry; this is only intended to be a quick example of some working test code. The testing framework used here is Mocha, and the assertion library used is chai. In the JavaScript community, most testing frameworks are built with BDD in mind. Each described in the code sample above represents a scenario or a higher-level requirements abstraction; whereas, each it represents a specific test. Within the tests, the only required element is the expect, without which the test will not deliver a valuable result.

Continuing this example, say that we receive a requirement that the add and subtract methods must be allowed to chain. How would we tackle that requirement? There are many ways, but in this case, I think I would like to do a quick redesign and then add some new tests. First, we will do the redesign, again driven by tests.

By placingonly on a describe or a test, we can isolate that describe/test. In this case, we want to isolate our add tests and begin making our change here:

it.only('adds two numbers', () => {
  // arrange
  let calc = new SimpleCalc(1);

  // act
  let result = calc.add(2).result;

  // assert
  expect(result).to.equal(3);
});

Previously, we have changed the test to use a constructor that takes a number. We have also reduced the number of parameters of the add function to a single parameter. Lastly, we have added a result value that must be used to evaluate the result of adding.

The test will fail because it does not use the same interface as the class, so now we must make a change to the class:

class SimpleCalc {
  constructor(value) {
    this._startingPoint = value || 0;
  }

  add(value) {
    return new SimpleCalc(this._startingPoint + value);
  }
  ... 
  get result() {
    return this._startingPoint;
  }
}

This change should cause our test to pass. Now, it's time to make a similar change for the subtract method. First, remove the only that was placed in the previous example:

it('subtracts two numbers', () => {
  // arrange
  let calc = new SimpleCalc(3);

  // act
  let result = calc.subtract(2).result;

  // assert
  expect(result).to.equal(1);
});

Now for the appropriate change in the class:

subtract(value) {
  return new SimpleCalc(this._startingPoint – value);
}

Out tests now pass again. The next thing we should do is create a test that verifies everything works together. We will leave this test up to you as an exercise, should you want to attempt it.