DEV Community

Thai Anh Duc
Thai Anh Duc

Posted on • Originally published at thaianhduc.com

The Process of Making Elegant Unit Tests

Original posted on my blog April 12, 2019

Unit tests are part of the job that developers do while building software. Some developers might not write unit tests. But, IMO, majority does. If you are one of those, how do you treat the unit test code comparing to the production code?

  • Do you think about maintainability?
  • Will you refactor the test to make it better? Note: I use the term refactoring from the Refactoring book by Martin Fowler.
  • Have you used the advantages that the test framework offer?

To be honest, I had not thought about that much. In the beginning of my career, I wrote tests that followed the current structure of the projects. I did not question that much. Over the time, I started to feel the pain so I made changes Unit Test from Pain to Joy which has served me well in that project.

Write Tests

Recently, I have had a chance to work on another project. I was tasked with writing unit tests (and integration tests) to get used to the system and to be able to run tests with multiple credentials, AKA login user.

Here is the test, not a real one of course. But the idea is the same. I want to run the test with different credentials. The username and password must be passed to the parameters. This is very useful when you look at a test report. By parameterizing the report will show values passed to the test.

[TestFixture]
internal class MultiCredentialsTest
{
    [Test]
    [TestCase("read_user", "P@ssword")]
    [TestCase("write_user", "P@ssword")]
    public void RunWithDifferentCredentials(string username, string password)
    {
        // The test body goes here.
    }
}

And there will be many of them. It worked as expected. But there are potential problems. Can you guess the problem?

  1. What happens when one of test users is changed either username or password?
  2. What if we want to add more test users into the test suites?

Think about the situation where there are hundreds even thousands of them. It will be a pain. I need a solution to centralize the test data. My process has started.

Make Them Better - Manageable

It was time to look at what NUnit offers. NUnit has supported TestCaseSource. You should check it out first if you have not known it. In the nutshell, it allows developers to centralize test data in a manageable manner. That was exactly what I was looking for.

I created a TestCredentialsSource to produce the same test data. I would prefer the name TestCredentialsFactory, but seems the "source" fits better in the unit test context.

internal class TestCredentialsSource
{
    public static  IEnumerable<object> ReadWriteUsers = new IEnumerable<object>{
        new object[]{"read_user", "P@ssword"},
        new object[]{"write_user", "P@ssword"}
    }
}

The test was rewritten with version V1. There are 2 versions for comparison.

[TestFixture]
internal class MultiCredentialsTest
{
    [Test]
    [TestCase("read_user", "P@ssword")]
    [TestCase("write_user", "P@ssword")]
    public void RunWithDifferentCredentials(string username, string password)
    {
        // The test body goes here.
    }

    [Test]
    [TestCaseSource(typeof(TestCredentialsSource), "ReadWriteUsers")]
    public void RunWithDifferentCredentials_V1(string username, string password)
    {
        // The test body goes here.
    }
}

In the test, I did not have to deal with test values. The test data was encapsulated in the TestCredentialsSource with the ReadWriteUsers static field.

Make Them Even Better - Reuse and Duplication

It was good with known specific set of users. There were certain tests that want to run with a specific user. It should be fairly easy with another property in the TestCredentialsSource

internal class TestCredentialsSource
{
    public static  IEnumerable<object> ReadWriteUsers = new IEnumerable<object>{
        new object[]{"read_user", "P@ssword"},
        new object[]{"write_user", "P@ssword"}
    }

    public static   IEnumerable<object> SpecificUser = new IEnumerable<object>{
        new object[] {"special_user", "P@ss12345"}
    }
}

What if I wanted to test with only "read_user"? What if I wanted to combine the "read_user" with "special_user" for another test? One option was to define them in the TestCredentialsSource. Which was still fine because it was still manageable in a single file. But it was awkward.

Was there any better alternative?

Yes, there was. Let's encapsulate the data in a class. Welcome to TestCredentials class.

internal class TestCredentials
{
    public string Username { get; }
    public string Password { get; }
    public TestCredentials(string username, string password)
    {
        Username = username;
        Password = password;
    }
    ///<summary>
    /// Convert the object into an array of properties object which can be used by the TestDataSource
    ///</summary>
    public object[] ToTestSource() => new object[] { Username, Password };

    public static TestCredentials ReadUser = new TestCredentials("read_user", "P@ssword");
    public static TestCredentials WriteUser = new TestCredentials("write_user", "P@ssword");
    public static TestCredentials SpecialUser = new TestCredentials("special_user", "P@ss12345");
}

The class supplied 3 factory methods to construct the needed credentials. This was the only single place where the data was provided without any duplication.
The TestCredentialsSource became much cleaner

internal class TestCredentialsSource
{
    public static  IEnumerable<object> ReadWriteUsers = new IEnumerable<object>{
        TestCredentials.ReadUser.ToTestSource(),
        TestCredentials.WriteUser.ToTestSource()
    }

    public static   IEnumerable<object> SpecificUser = new IEnumerable<object>{
        TestCredentials.SpecialUser.ToTestSource()
    }
}

Cool! The data has gone from the source definition. But there was still one thing that I did not like much - the setup of "SpecificUser" in the TestCredentialsSource. Having a source for a single value did not sound right to me.

There was a solution - convert the TestCredentials to a source that NUnit can understand. Implement the IEnumerable<TestCaseData>. TestCaseData is defined by the NUnit framework

internal class TestCredentials : IEnumerable<TestCaseData>
{
    public string Username { get; }
    public string Password { get; }
    public TestCredentials(string username, string password)
    {
        Username = username;
        Password = password;
    }
    ///<summary>
    /// Convert the object into an array of properties object which can be used by the TestDataSource
    ///</summary>
    public object[] ToTestSource() => new object[] { Username, Password };

    public static TestCredentials ReadUser = new TestCredentials("read_user", "P@ssword");
    public static TestCredentials WriteUser = new TestCredentials("write_user", "P@ssword");
    public static TestCredentials SpecialUser = new TestCredentials("special_user", "P@ss12345");

    public IEnumerator<TestCaseData> GetEnumerator()
    {
        return new List<TestCaseData>
            {
                new TestCaseData(Username, Password)
            }.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

With the in place, I could write the 2 below tests. There was no limitation of what combinations I could make of test credentials.

[Test]
[TestCaseSource(typeof(TestCredentials), "SpecialUser")]
public void RunWithDifferentCredentials_SpecialUser(string username, string password)
{
    // The test body goes here.
}

[Test]
[TestCaseSource(typeof(TestCredentials), "SpecialUser")]
[TestCaseSource(typeof(TestCredentials), "WriteUser")]
public void RunWithDifferentCredentials_CombinedUsers(string username, string password)
{
    // The test body goes here.
}

The End

The good result does not come by accident. I could have stopped at any step in the process. By pushing a little bit further, by asking the right questions, the end result was more than I had expected.
If you are writing tests code, should you look at them again and ask questions? Give it a try and see how far it takes you.

Top comments (0)