Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Test-Driven Development in C#

5.00/5 (10 votes)
27 Aug 2024CPOL13 min read 42.8K   327  
TDD in C# through a simple example
This article discusses Test-Driven Development in C# through a simple example.

Contents

  1. Introduction
  2. Development Environment
  3. Prerequisites
  4. Fake it!
  5. Triangulation
  6. Multiple Translations
  7. Reverse Translation
  8. File Loading
    1. TranslatorDataSourceTest
    2. TranslatorParserTest
    3. TranslatorLoaderTest
  9. Class Diagram
  10. Test Results
  11. Code Coverage
  12. Running the Source Code
  13. Is TDD a Time Waster?
  14. Conclusion
  15. History

Introduction

The traditional approach to writing unit tests involves writing tests to check the validity of your code. First, you begin by writing the code and then you write the tests. This is the opposite of test-driven development.

Image 1

Test-driven development (TDD) involves writing tests before writing code, as shown in the workflow above.

First, the test is written and must fail at the beginning. Then, we write the code so that the test passes. Then, the test must be executed and must succeed. Then, the code is refactored. Then, the test must be performed again to ensure that the code is correct.

To summarize, this is done in five steps:

  1. Write a test.
  2. The test must fail at the beginning.
  3. Write the code so that the test passes.
  4. Execute the test and make sure it passes.
  5. Refactor the code.

We can notice that in the workflow explained above, the tests are executed after the code has been refactored. This ensures that the code remains correct after refactoring.

This article will discuss TDD in C# through a simple example. The purpose of the example is to describe each step of TDD. The example will be developed in C# and the testing framework used is xUnit. We will be using Moq for mocking and dotCover for code coverage. We will be creating a multilingual translator through TDD. When writing code, we will try to respect SOLID principles and achieve 100% code coverage.

Development Environment

  • Visual Studio 2022 >= 17.8.0
  • .NET 8.0

Prerequisites

  • C#
  • xUnit
  • Moq

Fake it!

The first task to achieve when using TDD is important: it must be so simple that the loop red-green-refactor can be completed quickly.

We first create a test class called TranslatorTest:

C#
public class TranslatorTest
{
}

Then we will create the first unit test in this class, we initialize an object of type Translator with the name "en-fr" and we will check if the name is correct:

C#
public class TranslatorTest
{
    [Fact]
    public void TestTranslatorName()
    {
        var translator = new Translator("en-fr");
        Assert.Equal("en-fr", translator.Name);
    }
}

The test will fail, which is what we want.

Now that we have reached the red bar, we will write some code so that the test passes. There are many ways to do this. We will use the "Fake it!" method. Specifically, it includes the minimum required to pass the test. In our case, it is enough to write a Translator class that returns the property Name of "en-fr":

C#
public class Translator
{
    public string Name => "en-fr";
}

We will create the code step by step by using simple and quick methods in each step. Now, if we run our unit test again, it will pass. But the code is not refactored. There is a redundancy. Indeed, "en-fr" is repeated twice. We will refactor the code:

C#
public class Translator(string name)
{
    public string Name => name;
}

After refactoring the code, we have to run the tests again to make sure the code is correct.

Code refactoring is a form of modifying code that preserves the execution of existing tests and obtains a software architecture with minimal defects. Some examples:

  • Remove duplicate code / move code.
  • Adjust private / public properties / methods.

We noticed that we have completed the cycle of TDD workflow. Now we can start the cycle over and over again with new tests.

Triangulation

In TDD, we write tests first, generating functional requirements before coding requirements. To refine the test, we will apply triangulation method.

Let's write a test that checks if a translation has been added to the translator (AddTranslation).

Testing will be done through GetTranslation method.

C#
[Fact]
public void TestOneTranslation()
{
    var translator = new Translator("en-fr");
    translator.AddTranslation("against", "contre");
    Assert.Equal("contre", translator.GetTranslation("against"));
}

If we run the test, we'll notice that it fails. Okay, so that's what we're looking for at this step.

First, we'll use the "Fake it!" method to pass the test:

C#
public class Translator(string name)
{
    public string Name => name;

    public void AddTranslation(string word, string translation)
    {
    }

    public string GetTranslation(string word) => "contre";
}

After running the test TestOneTranslation, we will notice that it passes.

But wait, there's code duplication. The keyword "contre" is repeated twice in the code. We will change the code to remove this duplication:

C#
public class Translator(string name)
{
    private readonly Dictionary<string, string> _translations = new();
    public string Name => name;

    public void AddTranslation(string word, string translation)
    {
        _translations.Add(word, translation);
    }

    public string GetTranslation(string word) => _translations[word];
}

After refactoring the code, we have to run the tests again to make sure the code is correct.

Let's add a test to check if the translator is empty:

C#
[Fact]
public void TestIsEmpty()
{
    var translator = new Translator("en-fr");
    Assert.True(translator.IsEmpty());
}

If we run the test, we'll notice that it fails. Okay, so that's what we're looking for at this step. Let's use the "Fake it!" method and write some code to pass the test:

C#
public class Translator(string name)
{
    [...]

    public bool IsEmpty() => true;
}

If we run the test, we'll notice that it passes.

Now, let's use triangulation technique by using two assertions to drive the generalization of the code:

C#
[Fact]
public void TestIsEmpty()
{
    var translator = new Translator("en-fr");
    Assert.True(translator.IsEmpty());
    translator.AddTranslation("against", "contre");
    Assert.False(translator.IsEmpty());
}

Now if we run the test again, It will fail because of the second assertion. This is called triangulation.

So let's fix this:

C#
public class Translator(string name)
{
    [...]

    public bool IsEmpty() => _translations.Count == 0;
}

If we run the test again, we'll notice that it passes.

Multiple Translations

One feature of the translator is the ability to manipulate multiple translations. This use case was not initially planned in our architecture. Let’s write the test first:

C#
[Fact]
public void TestMultipleTranslations()
{
    var translator = new Translator("en-fr");
    translator.AddTranslation("against", "contre");
    translator.AddTranslation("against", "versus");
    Assert.Equal<string[]>(["contre", "versus"], 
                 translator.GetMultipleTranslations("against"));
}

If we run the test, we'll notice that it fails. Okay, so that's what we're looking for at this step. First, we will use the "Fake it!" method to pass the test by modifying the method AddTranslation and adding the GetMultipleTranslations method:

C#
public class Translator(string name)
{
    private readonly Dictionary<string, List<string>> _translations = new();
    public string Name => name;

    public string[] GetMultipleTranslation(string word) => ["contre", "versus"];

    [...]
}

After running the test TestMultipleTranslations, we will notice that it passes. But wait, there's code duplication. The string array ["contre", "versus"] is repeated twice in the code. We will change the code to remove this duplication:

C#
public class Translator(string name)
{
    private readonly Dictionary<string, List<string>> _translations = new();
    public string Name => name;

    public void AddTranslation(string word, string translation)
    {
        if (_translations.TryGetValue(word, out var translations))
        {
            translations.Add(translation);
        }
        else
        {
            _translations.Add(word, [translation]);
        }
    }

    public string[] GetMultipleTranslation(string word) => [.. _translations[word]];

    public string GetTranslation(string word) => _translations[word][0];

    public bool IsEmpty() => _translations.Count == 0;
}

If we run the test again, we'll notice that it passes. Let's do some refactoring and rename GetMultipleTranslations to GetTranslation:

C#
public class Translator(string name)
{
    private readonly Dictionary<string, List<string>> _translations = new();
    public string Name => name;

    public void AddTranslation(string word, string translation)
    {
        if (_translations.TryGetValue(word, out var translations))
        {
            translations.Add(translation);
        }
        else
        {
            _translations.Add(word, [translation]);
        }
    }

    public string[] GetTranslation(string word) => [.. _translations[word]];

    public bool IsEmpty() => _translations.Count == 0;
}

We also have to change our tests:

C#
public class TranslatorTest
{
    [Fact]
    public void TestTranslatorName()
    {
        var translator = new Translator("en-fr");
        Assert.Equal("en-fr", translator.Name);
    }

    [Fact]
    public void TestIsEmpty()
    {
        var translator = new Translator("en-fr");
        Assert.True(translator.IsEmpty());
        translator.AddTranslation("against", "contre");
        Assert.False(translator.IsEmpty());
    }

    [Fact]
    public void TestOneTranslation()
    {
        var translator = new Translator("en-fr");
        translator.AddTranslation("against", "contre");
        Assert.Equal<string[]>(["contre"], translator.GetTranslation("against"));
    }

    [Fact]
    public void TestMultipleTranslations()
    {
        var translator = new Translator("en-fr");
        translator.AddTranslation("against", "contre");
        translator.AddTranslation("against", "versus");
        Assert.Equal<string[]>(["contre", "versus"], translator.GetTranslation("against"));
    }
}

Reverse Translation

Now suppose we want to consider translation in both directions, for example, a bilingual translator. Let's create the test first:

C#
[Fact]
public void TestReverseTranslation()
{
    var translator = new Translator("en-fr");
    translator.AddTranslation("against", "contre");
    Assert.Equal<string[]>(["against"], translator.GetTranslation("contre"));
}

If we run the test, we'll notice that it fails. Okay, so that's what we're looking for at this step. Now, let's write the code to pass the test by using the "Fake it!" method:

C#
public string[] GetTranslation(string word)
{
    if (_translations.TryGetValue(word, out var translations))
    {
        return [.. translations];
    }

    // Try reverse translation
    return ["against"];
}

The test will pass. But there is a code duplication. Indeed, "against" is repeated twice. So let's refactor the code:

C#
public string[] GetTranslation(string word)
{
    if (_translations.TryGetValue(word, out var translations))
    {
        return [.. translations];
    }

    // Try reverse translation
    return [.. from t in _translations
               where t.Value.Contains(word)
               select t.Key];
}

If we run the test again, we'll notice that it passes.

File Loading

Now, let's handle loading translations from a data source (such as an external text file). Let's focus on external text files for now. The input format will be a text file where the first line contains the name of the translator and the other lines contain words separated by " = " . Here is an example:

en-fr
against = contre
against = versus

Here is the list of tests we will perform:

  1. Empty file.
  2. File containing only translator name.
  3. File with translations.
  4. Wrong file.

First, we'll use mocks to write tests. Then we'll write code along the way. Then we'll refactor the code. Finally, we'll test the code to make sure we refactored correctly and everything is working properly. We will create three new test classes:

  • TranslatorDataSourceTest: We will test a translator loaded from an external data source.
  • TranslatorParserTest: We will test the parsing of loaded translator data.
  • TranslatorLoaderTest: We will test the loading of translator data loaded from an external data source.

TranslatorDataSourceTest

Empty Translator Name

First, let's write the test:

C#
[Fact]
public void TestEmptyTranslatorName()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetName())
        .Returns(string.Empty);

    var translator = new Translator(mockTranslatorParser.Object);
    Assert.Equal(string.Empty, translator.Name);
}

The test will fail. Okay, so that's what we're looking for at this step. We will use the interface ITranslatorParser to parse translator data loaded from external data source. Following is the interface ITranslatorParser:

C#
public interface ITranslatorParser
{
    string GetName();
}

Let's modify the Translator class using the "Fake it!" method to pass the test:

C#
public class Translator
{
    private readonly Dictionary<string, List<string>> _translations;
    public string Name { get; private set; }

    public Translator(string name)
    {
        _translations = [];
        Name = name;
    }

    public Translator(ITranslatorParser parser)
    {
        Name = string.Empty;
    }

    [...]
}

If we run the test again, we'll notice that it passes. But wait, there's a duplication in the code. Indeed, string.Empty is repeated twice. So, let's do some refactoring:

C#
public class Translator
{
    private readonly Dictionary<string, List<string>> _translations;
    public string Name { get; private set; }

    public Translator(string name)
    {
        _translations = [];
        Name = name;
    }

    public Translator(ITranslatorParser parser)
    {
        Name = parser.GetName();
    }
}

If we run the test again, we'll notice that it passes.

No Translation

First, let's start by writing a test:

C#
[Fact]
public void TestEmptyFile()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetTranslations())
        .Returns([]);

    var translator = new Translator(mockTranslatorParser.Object);
    Assert.Equal([], translator.GetTranslation("against"));
}

We will notice that the test will fail. Okay, so that's what we're looking for at this step. First, let's modify the interface ITranslatorParser:

C#
public interface ITranslatorParser
{
    string GetName();
    Dictionary<string, List<string>> GetTranslations();
}

Then, let's write some code to pass the test using the "Fake it!" method:

C#
public class Translator
{
    private readonly Dictionary<string, List<string>> _translations;
    public string Name { get; private set; }

    public Translator(string name)
    {
        _translations = [];
        Name = name;
    }

    public Translator(ITranslatorParser parser)
    {
        _translations = [];
        Name = parser.GetName();
    }

    [...]
}

If we run the test again, we'll notice that it passes. But wait, there's a duplication in the code. In fact, the translator initialization is repeated twice. So, let's do some refactoring:

C#
public class Translator
{
    private readonly Dictionary<string, List<string>> _translations;
    public string Name { get; private set; }

    public Translator(string name)
    {
        _translations = [];
        Name = name;
    }

    public Translator(ITranslatorParser parser)
    {
        _translations = parser.GetTranslations();
        Name = parser.GetName();
    }

  [...]
}

If we run the test again, we'll notice that it passes.

File with only Translator Name

First, let's start by writing a test:

C#
[Fact]
public void TestTranslatorName()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetName())
        .Returns("en-fr");

    var translator = new Translator(mockTranslatorParser.Object);
    Assert.Equal("en-fr", translator.Name);
}

We will notice that the test will pass because we have written the interface ITranslatorParser and changed the Translator class. Currently, this unit does not require refactoring.

One Translation

First, let's start by writing a test:

C#
[Fact]
public void TestOneTranslation()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetTranslations())
        .Returns(new Dictionary<string, List<string>>
        {
                {"against", ["contre"] }
        });

    var translator = new Translator(mockTranslatorParser.Object);
    Assert.Equal<string[]>(["contre"], translator.GetTranslation("against"));
}

We will notice that the test will pass because we have written the interface ITranslatorParser and changed the Translator class. Currently, this unit does not require refactoring.

Multiple Translations

First, let's start by writing a test:

C#
[Fact]
public void TestMultipleTranslations()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetTranslations())
        .Returns(new Dictionary<string, List<string>>
        {
            { "against", ["contre", "versus"]}
        });

    var translator = new Translator(mockTranslatorParser.Object);
    Assert.Equal<string[]>(["contre", "versus"], translator.GetTranslation("against"));
}

We will notice that the test will pass because we have written the interface ITranslatorParser and changed the Translator class. Currently, this unit does not require refactoring.

Wrong File

First, let's start by writing a test:

C#
[Fact]
public void TestErroneousFile()
{
    var mockTranslatorParser = new Mock<ITranslatorParser>();
    mockTranslatorParser
        .Setup(dp => dp.GetTranslations())
        .Throws(new TranslatorException("The file is erroneous."));

    Assert.Throws<TranslatorException>(() => new Translator(mockTranslatorParser.Object));
}

We will notice that the test will pass because we have written the interface ITranslatorParser and changed the Translator class. Currently, this unit does not require refactoring.

TranslatorParserTest

Now let's create a class to parse loaded translator data through ITranslatorLoader, which loads translator data from external data source.

Empty Translator Name

First, let's start by writing a test:

C#
[Fact]
public void TestEmptyTranslatorName()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns([]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    Assert.Equal(string.Empty, translatorParser.GetName());
}

The test will fail. Okay, so that's what we're looking for at this step. We will use an interface ITranslatorLoader to load translator data from an external data source. The following is the interface ITranslatorLoader:

C#
public interface ITranslatorLoader
{
    string[] GetLines();
}

Let's write some code to pass the test using the "Fake it!" method:

C#
public class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    public string GetName() => string.Empty;
    public Dictionary<string, List<string>> GetTranslations() => new();
}

The test will pass. Let's move on to other units.

No Translation

Let's start by writing a test:

C#
[Fact]
public void TestNoTranslation()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns([]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    Assert.Equal([], translatorParser.GetTranslations());
}

The test will pass. Let's move on to other units.

Translator Name

Let's start by writing a test:

C#
[Fact]
public void TestTranslatorName()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns(["en-fr"]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    Assert.Equal("en-fr", translatorParser.GetName());
}

The test will fail. Okay, so that's what we're looking for in this step. Let's write some code to pass the test using the "Fake it!" method:

C#
public class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    public string GetName() => "en-fr";
    public Dictionary<string, List<string>> GetTranslations() => new();
}

The test will pass. But wait, there is a duplication in the code and the test TestEmptyTranslatorName fails. So let's solve this issue:

C#
public class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    private readonly string[] _lines = loader.GetLines();

    public string GetName() => _lines.Length > 0 ? _lines[0] : string.Empty;
    public Dictionary<string, List<string>> GetTranslations() => new();
}

Now, the test will pass.

One Translation

Let's start by writing a test:

C#
[Fact]
public void TestOneTranslation()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns(["en-fr", "against = contre"]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    var expected = new Dictionary<string, List<string>>
    {
        {"against", ["contre"]}
    };
    Assert.Equal(expected, translatorParser.GetTranslations());
}

The test will fail. Okay, so that's what we're looking for at this step. Let's write some code to pass the test using the "Fake it!" method:

C#
public class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    private readonly string[] _lines = loader.GetLines();

    public string GetName() => _lines.Length > 0 ? _lines[0] : string.Empty;
    public Dictionary<string, List<string>> GetTranslations() => new()
    {
        {"against", ["contre"]}
    };
}

The test will pass. But wait, there is a duplication in the code and the test TestNoTranslation fails. So let's fix this:

C#
public partial class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    private static readonly Regex TranslatorRegex = new(@"^(?<key>\w+) = (?<value>\w+)$");

    private readonly string[] _lines = loader.GetLines();

    public string GetName() => _lines.Length > 0 ? _lines[0] : string.Empty;

    public Dictionary<string, List<string>> GetTranslations()
    {
        var translator = new Dictionary<string, List<string>>();

        if (_lines.Length <= 1)
        {
            return translator;
        }

        for (var i = 1; i < _lines.Length; i++)
        {
            var line = _lines[i];
            var match = TranslatorRegex.Match(line);

            var key = match.Groups["key"].Value;
            var value = match.Groups["value"].Value;

            if (translator.TryGetValue(key, out var translations))
            {
                translations.Add(value);
            }
            else
            {
                translator.Add(key, [value]);
            }
        }

        return translator;
    }
}

Now the test will pass. The method GetTranslations just parses the lines loaded by ITranslatorLoader.

Multiple Translations

Let's start by writing a test:

C#
[Fact]
public void TestMultipleTranslations()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns(["en-fr", "against = contre", "against = versus"]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    var expected = new Dictionary<string, List<string>>
    {
        {"against", ["contre", "versus"]}
    };
    Assert.Equal(expected, translatorParser.GetTranslations());
}

We will notice that the test will pass because we implemented TranslatorParser. Currently, this unit does not require refactoring.

Wrong File

One of the features we haven't implemented yet is handling the loading of wrong files. This use case was not initially planned in our architecture. Let's write the test first:

C#
[Fact]
public void TestErroneousFile()
{
    var mockTranslatorLoader = new Mock<ITranslatorLoader>();
    mockTranslatorLoader
        .Setup(dl => dl.GetLines())
        .Returns(["en-fr", "against = ", "against = "]);

    var translatorParser = new TranslatorParser(mockTranslatorLoader.Object);
    Assert.Throws<TranslatorException>(translatorParser.GetTranslations);
}

The test will fail. Okay, so that's what we're looking for at this step. Let's update the code to pass the test:

C#
public partial class TranslatorParser(ITranslatorLoader loader) : ITranslatorParser
{
    private static readonly Regex TranslatorRegex = new(@"^(?<key>\w+) = (?<value>\w+)$");

    private readonly string[] _lines = loader.GetLines();

    public string GetName() => _lines.Length > 0 ? _lines[0] : string.Empty;

    public Dictionary<string, List<string>> GetTranslations()
    {
        var translator = new Dictionary<string, List<string>>();

        if (_lines.Length <= 1)
        {
            return translator;
        }

        for (var i = 1; i < _lines.Length; i++)
        {
            var line = _lines[i];
            var match = TranslatorRegex.Match(line);

            if (!match.Success)
            {
                throw new TranslatorException("The file is erroneous.");
            }

            var key = match.Groups["key"].Value;
            var value = match.Groups["value"].Value;

            if (translator.TryGetValue(key, out var translations))
            {
                translations.Add(value);
            }
            else
            {
                translator.Add(key, [value]);
            }
        }

        return translator;
    }
}

Now, we'll notice that the test will pass because we handled the case of wrong files by throwing TranslatorException in case of a wrong line.

TranslatorLoaderTest

Now, we will create a class that loads translator data from an external file.

Empty File

Let's start with the first test that tests an empty file:

C#
[Fact]
public void TestEmptyFile()
{
    var translatorLoader = new TranslatorLoader(@"..\..\..\..\..\data\translator-empty.txt");
    Assert.Equal([], translatorLoader.GetLines());
}

The test will fail. Okay, so that's what we're looking for at this step. Now, let's write some code to pass the test using the "Fake it!" method:

C#
public class TranslatorLoader(string path) : ITranslatorLoader
{
    public string[] GetLines() => [];
}

Now the test will pass. But wait, there's code duplication. In fact, The empty string array is repeated twice in the code. So, let's do some refactoring:

C#
public class TranslatorLoader(string path) : ITranslatorLoader
{
    public string[] GetLines() => File.ReadAllLines(path);
}

Now, if we run the test again, we'll see that it passes.

Files with only Translator Names

Now, let's use the following text file (translator-name.txt):

en-fr

Let's start by writing the test:

C#
[Fact]
public void TestTranslatorName()
{
    var translatorLoader = new TranslatorLoader(@"..\..\..\..\..\data\translator-name.txt");
    Assert.Equal<string[]>(["en-fr"], translatorLoader.GetLines());
}

The test will pass because we implemented the class TranslatorLoader in the previous test. Let's move on to other units.

Files with Translations

Now, let's use the following translator file (translator.txt):

en-fr
against = contre
against = versus

Let's start by writing the test:

C#
[Fact]
public void TestMultipleTranslations()
{
    var translatorLoader = new TranslatorLoader(@"..\..\..\..\..\data\translator.txt");
    Assert.Equal<string[]>(["en-fr", "against = contre", "against = versus"], translatorLoader.GetLines());
}

Again, the test will pass because we implemented the TranslatorLoader class in previous tests.

Wrong File

Now, let's use the following translator file (translator-erroneous.txt):

en-fr
against = 
against = 

Let's write the test first:

C#
[Fact]
public void TestErroneousFile()
{
    var translatorLoader = new TranslatorLoader(@"..\..\..\..\..\data\translator-erroneous.txt");
    Assert.Equal<string[]>(["en-fr", "against = ", "against = "], translatorLoader.GetLines());
}

Again, the test will pass because we implemented the TranslatorLoader class in previous tests. We completed the testing and created the TranslatorLoader class responsible for loading translator data from an external file.

Class Diagram

We have finished coding our multilingual translator through TDD.

The following is the class diagram:

Image 2

Test Results

If we run all the tests, we'll notice that they all pass:

Image 3

You can find test results on GitHub Actions.

Code Coverage

Here is the code coverage:

Image 4

We'll notice that we've reached 100% code coverage. This is one of the advantages of TDD.

You can find code coverage report on Codecov.

Running the Source Code

To run the source code, do the following:

  1. Download the source code.
  2. Open tdd.sln in Visual Studio 2022.
  3. Run all the tests in the solution.
  4. To get code coverage, you can use dotCover.

Is TDD a Time Waster?

At first, it does kind of look like a waste of time. In the beginning of the project, it slows the development progress because we have to invest time on the hassle of writing tests, but as the project grows, it actually saves more time than the time used in the beginning. It saves time by making future changes quicker and less risky by ensuring that the changes don't break the code. It also saves time by making sure that the code that you write doesn't deviate from the real purpose and the requirements.

If the purpose of the project is clear, if the project is going to evolve over time and using TDD will save time while it grows, and if you really know what you're doing, you can then decide whether to use TDD or not.

Conclusion

This article demonstrates TDD in C# through a very simple example.

We can notice with TDD:

  • Unit tests were written.
  • We achieved 100% code coverage.
  • We spent less time debugging.
  • The code respects SOLID principles.
  • The code is maintainable, flexible and extensible.
  • The code is more coherent.
  • Behavior is clear.

TDD is very useful when the code is continuously improved.

TDD provides additional benefits because developers think about software in terms of small units that can be written and tested independently and integrated together later.

History

  • 12th December, 2023
    • Initial release
  • 3rd March, 2024
    • Updated TranslatorParser.cs
    • Updated Test CI workflow
    • Added coverlet.msbuild
    • Removed coverlet.collector
    • Bump Microsoft.NET.Test.Sdk from 17.8.0 to 17.9.0
    • Bump xunit from 2.6.3 to 2.7.0
    • Bump xunit.runner.visualstudio from 2.5.5 to 2.5.7
  • 28th August, 2024
    • Bump Microsoft.NET.Test.Sdk from 17.9.0 to 17.11.0
    • Bump xunit from 2.7.0 to 2.9.0
    • Bump xunit.runner.visualstudio from 2.5.7 to 2.8.2

License

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