Click here to Skip to main content
15,888,195 members
Articles / Programming Languages / C#

TDD and Refactoring to Patterns in C#: How to Write a Cron Parser

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
29 Nov 2023CPOL10 min read 5.2K   5   7  
How to design a cutting-edge C# application by utilizing best practices?
We emphasize the design of a straightforward cron parser in C#, utilizing best practices in software engineering, including test-driven development and refactoring.

Introduction

Designing software is challenging, and designing good software is even more so. That's why best practices and guidelines have been established to avoid complexities and pitfalls. In this series of articles, we will delve into techniques such as test-driven development and progressive refactoring to create scalable software. The example we will use for illustration is a cron parser: given a string pattern representing an occurrence, we will display the next execution time.

Three authoritative textbooks (and bestsellers) on this topic merit consultation. They are widely recognized as standard references and are universally employed in courses.

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.

    Image 1

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

    C#
    public class RootCronExpression
    {
        public string Pattern { get; set; }
    
        private RootCronExpression(string pattern)
        {
            Pattern = pattern;
        }
    
        public static RootCronExpression Parse(string pattern)
        {
            return new RootCronExpression(pattern);
        }
            
        public DateTime GiveNextExecutionFrom(DateTime reference)
        {
            return reference;
        }
    }

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:

    C#
    public class RootCronExpressionTests
    {
        [Test]
        public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_1()
        {
            // Arrange
            var expression = "0 22 * * 4";
            var root = RootCronExpression.Parse(expression);
            var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
    
            // Act
            var result = root.GiveNextExecutionFrom(referenceDate);
    
            // Assert
            var expected = new DateTime(2023, 11, 30, 22, 0, 0);
            Assert.IsTrue(result == expected);
        }
    
        [Test]
        public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_2()
        {
            // Arrange
            var expression = "5 0 * 8 *";
            var root = RootCronExpression.Parse(expression);
            var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
    
            // Act
            var result = root.GiveNextExecutionFrom(referenceDate);
    
            // Assert
            var expected = new DateTime(2024, 8, 1, 0, 5, 0);
            Assert.IsTrue(result == expected);
        }
    
        [Test]
        public void GiveNextExecutionFromShouldReturnCorrectResultWithTestCase_3()
        {
            // Arrange
            var expression = "5 0 21 11 6";
            var root = RootCronExpression.Parse(expression);
            var referenceDate = new DateTime(2023, 11, 26, 12, 20, 0);
    
            // Act
            var result = root.GiveNextExecutionFrom(referenceDate);
    
            // Assert
            var expected = new DateTime(2026, 11, 21, 0, 5, 0);
            Assert.IsTrue(result == expected);
        }
    }
  • 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.

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.

Our task is now to implement a simplified code for the cron parser to make the tests pass and, thereby, have a first version of our application. It is time to delve deeper into the C# code for the parser. The code we will write may not be the most efficient or the most pleasant to read, but the objective here is to have a working application. Refactoring will come later in the subsequent sections.

Make Tests Pass

Code in Visual Studio

We modify the GiveNextExecutionFrom method with the following code:

C#
public DateTime GiveNextExecutionFrom(DateTime reference)
{
    var nextOccurrences = GetDates(reference);
    var fields = Pattern.Split(' ');

    if (fields[3] != "*") // month
    {
        nextOccurrences = nextOccurrences.Where
                          (t => t.Month == Convert.ToInt32(fields[3])).ToList();
    }

    if (fields[2] != "*") // dayOfMonth
    {
        nextOccurrences = nextOccurrences.Where
                          (t => t.Day == Convert.ToInt32(fields[2])).ToList();
    }

    if (fields[4] != "*") // dayOfWeek
    {
        nextOccurrences = nextOccurrences.Where
                   (t => (int)t.DayOfWeek == Convert.ToInt32(fields[4])).ToList();
    }

    if (fields[1] != "*") // hour
    {
        nextOccurrences = nextOccurrences.Select(t => new DateTime
            (t.Year, t.Month, t.Day, Convert.ToInt32(fields[1]), 0, 0)).ToList();
    }

    if (fields[0] != "*") // minute
    {
        nextOccurrences = nextOccurrences.Select(t => new DateTime
        (t.Year, t.Month, t.Day, t.Hour, Convert.ToInt32(fields[0]), 0)).ToList();
    }

    return nextOccurrences.Where(x => x >= reference).Min();
}

private List<DateTime> GetDates(DateTime reference)
{
    var endDate = new DateTime(2099, 12, 31);
    return Enumerable.Range(0, 1 + endDate.Subtract(reference).Days).Select
                                   (offset => reference.AddDays(offset)).ToList();
}

Disclaimer

This code is probably not the most sophisticated solution for our current problem: we first generate all possible dates until 2099 and then progressively filter them based on the provided patterns. In the end, we return the earliest date found.

Running the Tests

In accordance with the TDD philosophy, we now run tests to check that they pass.

Image 2

Now that we have successfully written working code and our tests have passed, we can move on to the second phase: refactoring the code to make it more scalable, readable, and extensible.

Refactor Code With the Interpreter Design Pattern

Why Undertake the Process of Refactoring?

Refactoring code is the process of restructuring existing computer code without changing its external behavior. The primary goal of refactoring is to improve the code's internal structure, making it easier to understand, maintain, and extend while preserving its functionality.

Here are some reasons why refactoring is often essential:

  • Refactoring improves code readability by organizing it in a more logical and understandable way. Clear and well-structured code is easier for developers to comprehend and work with.

  • A well-refactored codebase is easier to maintain. It reduces the likelihood of introducing bugs when making changes and allows for faster bug detection and fixing.

  • Refactoring makes code more scalable by removing duplication, improving design, and ensuring that the codebase can easily accommodate future changes and additions.

  • Refactoring helps eliminate code duplication, reducing the chances of errors and making the codebase more consistent.

  • Refactoring addresses "code smells," which are signs of potential issues in the code. Common code smells include duplicated code, long methods, and complex conditional statements.

  • Refactoring allows for improvements in the overall design of the code. This includes better organization of classes, modules, and functions, leading to a more maintainable and extensible system.

  • In some cases, refactoring can lead to performance improvements by identifying and eliminating inefficient code patterns.

  • Refactoring makes it easier to introduce new features or modify existing ones. A well-structured codebase allows developers to build upon existing functionality without introducing unnecessary complexity.

  • Clean and well-refactored code enhances the code review process. It facilitates collaboration among team members and helps catch potential issues early.

  • Developers working with clean, well-organized code can be more productive. Refactoring reduces cognitive load and makes it easier for developers to focus on solving problems rather than deciphering confusing code.

In summary, refactoring is a crucial aspect of the software development process that contributes to the long-term health and sustainability of a codebase. It allows for continuous improvement, adaptation to changing requirements, and the creation of maintainable and efficient software systems.

What Is the Problem in Our Case?

There isn't a fundamental issue as such, but while the code may achieve its intended functionality, it does so in a way that is not very readable. For instance, we are compelled to provide comments explaining the various items of the fields array to comprehend their functions (such as month, day of the week, and so forth). It would be more beneficial if these distinctions were directly reflected in the code.

C#
if (fields[3] != "*") // month
{
    nextOccurrences = nextOccurrences.Where
                      (t => t.Month == Convert.ToInt32(fields[3])).ToList();
}

if (fields[2] != "*") // dayOfMonth
{
    nextOccurrences = nextOccurrences.Where
                      (t => t.Day == Convert.ToInt32(fields[2])).ToList();
}

if (fields[4] != "*") // dayOfWeek
{
    nextOccurrences = nextOccurrences.Where
                      (t => (int)t.DayOfWeek == Convert.ToInt32(fields[4])).ToList();
}

if (fields[1] != "*") // hour
{
    nextOccurrences = nextOccurrences.Select(t => new DateTime
        (t.Year, t.Month, t.Day, Convert.ToInt32(fields[1]), 0, 0)).ToList();
}

if (fields[0] != "*") // minute
{
    nextOccurrences = nextOccurrences.Select(t => new DateTime
        (t.Year, t.Month, t.Day, t.Hour, Convert.ToInt32(fields[0]), 0)).ToList();
}

In the next phase of our development process, we will address these issues through refactoring while adhering to best practices in software design.

What are Design Patterns?

Design patterns are reusable solutions to common problems encountered in software design. They represent best practices for solving specific design issues and provide general templates or blueprints for creating software structures. Design patterns help streamline the development process by offering tested and proven solutions that can be adapted to various scenarios.

Here are some key characteristics of design patterns:

  • Design patterns encapsulate proven solutions to recurring design problems. They have been used and refined by experienced developers over time.

  • Design patterns provide a level of abstraction that allows developers to focus on high-level design concepts rather than dealing with low-level implementation details.

  • Design patterns promote flexibility and adaptability in software design. They can be customized and extended to suit different requirements.

  • Design patterns establish a common vocabulary among developers, facilitating communication and understanding of design decisions.

  • Design patterns promote encapsulation by separating responsibilities and functionalities into distinct components.

Some well-known design patterns include the Singleton pattern, Factory pattern, Observer pattern, and MVC (Model-View-Controller) pattern. Each pattern addresses specific design challenges and can be applied in various contexts to improve the overall structure and maintainability of software systems.

Design patterns underwent extensive scrutiny in the late 1990s and became the focal point of a renowned and best-selling book (Design Patterns).

Yes and Concretely? Enter the Interpreter

A cron expression can be likened to a language; it seeks to convey future dates from a string, much like how English or French conveys meaning from sentences. Interestingly, there exists a design pattern tailored for this purpose! When dealing with a language, it can establish a representation for interpreting sentences — enter the Interpreter pattern.

Image 3

Purists might find it surprising to employ the Interpreter pattern for such a straightforward grammar (given that a cron expression is essentially just a string) or to assert its use for just a few lines of code. However, our aim is to demonstrate the process of refactoring.

This refactoring, along with the explicit modeling of the various fields, will enable us to implement more intricate scenarios.

Code in Visual Studio

  • Add a new interface named ICronField.cs with the following code:
    C#
    public interface ICronField
    {
        public List<DateTime> GiveNextOccurrencesFrom(List<DateTime> references);
    }
  • Add a new class named MonthCronField.cs with the following code:
    C#
    public class MonthCronField : ICronField
    {
        private RootCronExpression Expression { get; set; }
    
        private string Pattern { get; set; }
    
        public MonthCronField(RootCronExpression expression)
        {
            Expression = expression;
            Pattern = expression.Pattern.Split(' ')[3];
        }
    
        public List<DateTime> GiveNextOccurrencesFrom(List<DateTime> candidates)
        {
            if (Pattern == "*") return candidates;  
            
            var month = Convert.ToInt32(Pattern);
    
            candidates = candidates.Where(t => t.Month == month).ToList();
            return candidates;
        }
    }

    This class explicitly encapsulates the logic specific to the month part of a cron expression. This code is decoupled from other logics (dayofweek, dayofmonth) within the RootCronExpression class.

  • Add the other classes (DayOfTheMonthCronField, DayOfTheWeekCronField, HourCronField, MinuteCronField) with their own logic (download the zip file to see the implementation).
  • Modify the RootCronExpression class with the following code:
    C#
    public DateTime GiveNextExecutionFrom(DateTime reference)
    {
        var nextOccurrences = GetDates(reference);
        nextOccurrences = _monthField.GiveNextOccurrencesFrom(nextOccurrences);
        nextOccurrences = _dayOfMonthField.GiveNextOccurrencesFrom(nextOccurrences);
        nextOccurrences = _dayOfWeekField.GiveNextOccurrencesFrom(nextOccurrences);
        nextOccurrences = _hourField.GiveNextOccurrencesFrom(nextOccurrences);
        nextOccurrences = _minuteField.GiveNextOccurrencesFrom(nextOccurrences);
    
        return nextOccurrences.Where(x => x >= reference).Min();
    }

Running the Tests

It remains to verify that no regressions have been introduced by this code change. Ultimately, this is the true raison d'être of writing tests upfront.

Image 4

Good news! Our application is still functional and provides accurate results.

So far, we have refactored our code to be more readable. However, the journey doesn't end here, and we can delve deeper into the refactoring process. Check out this page to discover how we utilized the Visitor design pattern to enhance the design.

History

  • 29th November, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Chief Technology Officer
France France
Nicolas is a software engineer who has worked across various types of companies and eventually found success in creating his startup. He is passionate about delving into the intricacies of software development, thoroughly enjoying solving challenging problems.


Comments and Discussions

 
-- There are no messages in this forum --