TDD and refactoring to patterns in C#: how to write a cron parser - Part 2

In this article, we will describe what test-driven development is and how it will be applied to our toy example.

To illustrate our topic, we will implement a cron parser: given a string pattern (for example, "0 22 * * 4"), we will determine when the next occurrence will happen and display, in plain English, what it means.

What is cron expression ?

A cron expression is a string representing a schedule in the cron format. The cron format is a time-based job-scheduling syntax used in Unix-like operating systems. It consists of five fields representing the minute, hour, day of the month, month, and day of the week, in that order. Each field can be a specific value, a range of values, or a wildcard ("*") representing all possible values.

For example, the cron expression "0 22 * * 4" can be interpreted as follows: minute: 0, hour: 22, day of the month: any day, month: any month, day of the week: Thursday (since 4 represents Thursday in the cron syntax).

In plain English, this expression means that the associated task will run at 10:00 PM every Thursday.

Our current goal is to write a parser in C# that, given a specific cron expression, returns the next occurrence indicated by the cron expression (naturally, this occurrence depends on the current date).

Disclaimer

In truth, our focus is not on the cron parser or any specific topic; our goal is to demonstrate the approach we are taking to write scalable, readable, and maintainable software. Additionally, we will not delve into the subtleties of a cron expression, such as "@weekly" for instance.

Code in Visual Studio

  • In Visual Studio, create a new Blank Solution project and give it a name.

  • In this solution, add a new Class Library project and name it CronParser.Data for example.

  • Add in this project a new class named RootCronExpression.cs with the following code.

 1public class RootCronExpression
 2{
 3    public string Pattern { get; set; }
 4
 5    private RootCronExpression(string pattern)
 6    {
 7        Pattern = pattern;
 8    }
 9
10    public static RootCronExpression Parse(string pattern)
11    {
12        return new RootCronExpression(pattern);
13    }
14		
15	public DateTime GiveNextExecutionFrom(DateTime reference)
16	{
17		return reference;
18	}
19}

This C# class simply models a cron expression.

What is test-driven developement (TDD) ?

Test-driven development (TDD) is a software development approach in which tests are written BEFORE the code they are intended to validate. The TDD process typically follows these steps:

  • Before writing any code, a developer writes a test that defines a new function or improvement of a function, which should fail initially since the function doesn't exist or the improvement hasn't been made.

  • The test is executed to ensure it fails. This step verifies that the test is correctly written and that it is testing the desired functionality.

  • We write the minimum amount of code necessary to pass the test. The code may not be perfect or complete but should satisfy the test.

  • All tests are run to ensure that the new code didn't break existing functionality.

  • If needed, the code is refactored to improve its structure or performance while ensuring that all tests still pass.

  • The process is repeated by writing a new test for the next piece of functionality, and continue the cycle.

In summary

TDD helps ensure that code is thoroughly tested and that new features or changes to existing features don't introduce bugs. It promotes a cycle of writing small amounts of code, testing it, and refining it, resulting in more maintainable and reliable software.

Code in Visual Studio

  • Add a new NUnit Test project and name it CronParser.Data.Tests for example.

  • In this project, add a project reference to CronParser.Data.

  • Add a new class named RootCronExpressionTests.cs with the following code.

 1public class RootCronExpressionTests
 2{
 3    [Test]
 4    public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_1()
 5    {
 6        // Arrange
 7        var expression = "0 22 * * 4";
 8        var root = RootCronExpression.Parse(expression);
 9        var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
10
11        // Act
12        var result = root.GiveNextExecutionFrom(referenceDate);
13
14        // Assert
15        var expected = new DateTime(2023, 11, 30, 22, 0, 0);
16        Assert.IsTrue(result == expected);
17    }
18
19    [Test]
20    public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_2()
21    {
22        // Arrange
23        var expression = "5 0 * 8 *";
24        var root = RootCronExpression.Parse(expression);
25        var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
26
27        // Act
28        var result = root.GiveNextExecutionFrom(referenceDate);
29
30        // Assert
31        var expected = new DateTime(2024, 8, 1, 0, 5, 0);
32        Assert.IsTrue(result == expected);
33    }
34
35    [Test]
36    public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_3()
37    {
38        // Arrange
39        var expression = "5 0 21 11 6";
40        var root = RootCronExpression.Parse(expression);
41        var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
42
43        // Act
44        var result = root.GiveNextExecutionFrom(referenceDate);
45
46        // Assert
47        var expected = new DateTime(2026, 11, 21, 0, 5, 0);
48        Assert.IsTrue(result == expected);
49    }
50}
  • We have only three tests here, and they are very simple. Purists might find this oversimplification shocking, but, once again, our goal is to illustrate quickly how to implement and progressively refactor a C# software. Naturally, in real-world scenarios with hundreds of thousands of lines of code, there will be thousands and thousands of tests, and some of them could be quite complicated.

  • There are usually naming conventions for test names, with terms like "should" or "must" often employed. Here, we adopt a simple convention: we content ourselves with enumerating the tests one by one.

Arrange-Act-Assert

We are employing the Arrange-Act-Assert pattern here. The Arrange-Act-Assert (AAA) pattern is a common structure for organizing unit tests. It consists of three main steps.

  • Arrange: Set up the necessary preconditions and inputs for the test. This step includes initializing objects, defining parameters, and preparing the test environment.

  • Act: Perform the specific action or operation that the test is intended to validate. This is the step where the code being tested is executed.

  • Assert: Verify that the actual outcome of the action matches the expected result. This step involves checking the state of the system or the return values to ensure they align with the expected behavior.

The AAA pattern provides a clear and systematic way to structure tests, making it easier to understand and maintain them. Each section has a distinct purpose, helping developers and teams write more effective and readable unit tests.

Now that our tests have been written, the next article (here) will focus on implementing them to ensure they pass successfully.