To alleviate this pain point, many development shops, our BIG YAM team included, have shifted development processes over to TDD. I will discuss TDD and create a simple Microsoft .Net Core API in an agile/scrum environment to help illustrate the process of starting with a test.
When creating an application, whether it’s a web application, mobile application, or desktop application, the first step is to understand the functionality of the application. This is often found in a requirements document or a spec sheet. In an agile/scrum project, requirements will typically be defined by user stories. User stories may list a series of conditions required to reach a specific outcome that ultimately satisfies the story, sometimes referred to as conditions of acceptance. Here is an example of a user story that summarizes a user interaction in a single sentence then lists simple conditions of acceptance to satisfy that story.
“As a website or mobile app user, I want to see a filtered list of locations so that I can find location types near me.
Location search is an API endpoint that can be consumed by web and mobile apps.User’s geolocation is required in order to search for locations.Search returns all locations within a selected radius in miles from the user’s location. Location type is an optional parameter.
Locations in search results include Location Name, Location Type, Street Address, City, State, Postal Code, Phone Number, Latitude, and Longitude.”
This will be used as a baseline for our initial unit tests. Let’s start by creating a new .Net Core Web project, which will be a single location search service that can be consumed by any app along with a corresponding MSTest project which will include our first unit test.
As you can see, the BIGYAM.TDDDemo.Web does not have any controllers, and BIGYAM.TDDDemo.Web.Tests does not contain any tests. The condition for the story state location search must be an API endpoint. A common thought process at this point is to create a new controller with endpoints. However, we will start with a basic unit test for a controller method that searches locations within a 25-mile radius since we want to follow the TDD process.
At this point, we are unable to compile and run the test since no code has been implemented for the Locations controller. We are going to need a Location class and a Locations controller with a Search method. Once the classes for LocationsController and Location has been created, we can then use the IDE tools to generate the method and properties that our test depends on automatically.
Now we can run the test. However, the Search method will throw a System.NotImplementedException and cause the test to fail.
Now that we have our first red light scenario with test results, it is time to add functionality to the controller. Create tests based on the conditions of acceptance before implementing any of the methods. Once all the failing tests are built, you can implement the code. For this example, the distance was calculated using the haversine formula as kilometers which are then converted to miles for calculating the distance between the user and the location.
As you may have noticed, application logic and location data are defined in the LocationsController. This means none of that code follows SOLID design principles. Let’s fix this by separating everything into a logic layer and data layer as well as a separate project for the Location model that is shared by all the application layers. To do this, we will add two new application tier projects to the solution each with their unit test project and interface project. This will allow us to separate the functionality into their logical domain and unit test them without knowing anything about the implementation of their dependencies. In this case, the API project (BIGYAM.TDDDemo.Web) will have a very thin code whose primary responsibility is to pass messages to and from the client. The logic layer (BIGYAM.TDDDemo.Logic) will contain all of the rules for the application that needs to be applied to the data. The data layer (BIGYAM.TDDDemo.Data) will contain the methods needed to retrieve data from the data sources. The location data in this example will be hardcoded in the data layer to keep it simple. A real-world application would most likely store the location data in a datastore such as SQL Server or MySQL. At this point, we should have all test classes built out with minimal code changes so that we can begin the refactor.
This is how the new classes look before implementation:
We can start refactoring the code by moving the location search logic into the logic layer and the location data into the data layer. Executing unit tests during the refactor makes it easier to identify breaking changes to the code and make corrections. Waiting until all the code changes are complete to start running unit tests can lead to an overwhelming number of failing tests and increases the risk of introducing bugs into the application. You may find yourself creating new unit tests during this process as changes to the implementation may lead to additional code being added. Again, any new code should not be introduced unless there is a valid test that asserts the need for that code.
Let’s take a look at how the key pieces of this project look after refacting.
And just for fun, code coverage results generated by Reshaper’s DotCover plugin:
As you can see in the LocationLogicTest class, refactoring the code and implementing a mocking framework to fake the data requests with test data has forced us to make use of dependency injection thus enforcing a design pattern that encourages SOLID software development principles. While it isn’t always possible to refactor code in such a manner in an existing application, unit tests can help identify dependencies that can make an application difficult to maintain. When developing a new application, TDD ensures that any new code created has a test that defines the purpose and expected behavior of that code. In an existing application, adding unit tests can help stabilize the application while making it more reliable and easier to maintain. They are also a great way to identify and tackle architectural flaws in an application.
Strictly adhering to Test-Driven Development principles is something that many development teams struggle with, as it takes a lot of discipline. As with problem solving, starting with a test that defines the problem and the possible outcomes make it much easier to generate a solution. If you fail early and fail often, then the only option left is to succeed. TDD might feel like a drag early in the process but will provide a huge helping hand and a bid of confidence as the project nears the finish line.
Need help with your website? BIG YAM’s Development team is ready to assist.