Mark Gilbert's Blog

Science and technology, served light and fluffy.

Resisting the woodpeckers – Builder Pattern

If builders built buildings the way programmers wrote programs, then the first woodpecker that came along would destroy civilization.
– (Gerald) Weinberg’s Second Law

A couple of months ago, I found a recommendation on my company’s intranet for "Growing Object-Oriented Software, Guided by Tests" by Steve Freeman and Nat Pryce.  I am an avid reader, but believe or not, I haven’t actually managed to read all the books yet, so I checked this one out from the library, and went through it. 

One of the real gems I found inside was a pattern I hadn’t seen before – the builder pattern (for those of you who have the book, this appears starting on page 258).  The goal here is to create data for a test in a way that keeps your test clean, but also gives you the flexibility to change that data when you need to.  It is also very expressive – you know exactly what part of the data is being changed just by reading the setup code.

Their example, which is what sold me on this pattern looks like this:

new AddressBuilder()
       .build();

This would get you a simple address, with (presumably) default, valid, values.  Next, the builder class would allow you to modify the defaults using methods like so:

new AddressBuilder()
       .withStreet("221b Baker Street")
       .withStreet2("London")
       .withPostCode("NW1 6XE")
       .build();

The methods withStreet(), withStreet2(), and withPostCode() would override the address 1, address 2, and postal code, respectively.  What’s more, this is far clearer than writing a method like UpdateTestData("221b Baker Street", “London”, "NW1 6XE") to do the same thing – is the second parameter modifying the street address or the city?  You no longer know at a glance, you now have to dig into the code to find out.

I’ve had to do things like this in the past numerous times, and my setup code for this has been wearisome-at-best to maintain.  Not only is this pattern clean and expressive, but it could start out simple and grow with my tests.  I could start out with the default case, and if I found that I later needed to test a case involving a custom postal code, I could add a method that allowed me to inject that value.  I wouldn’t have to touch any of my other tests, or do anything crazy with my setup logic – I would just have to add a method, and chain it for the one test that needed it.

I vowed that my next project would use this pattern, and in the last couple of weeks, I got the opportunity to put it to good use.  I was building a mechanism for NLog that would allow me to configure it from a database (rather than from web.config/app.config; I won’t go into detail on how this is done, but it turns out to be rather straightforward: https://github.com/nlog/NLog/wiki/Configuration-API ).  I would pass in a Dictionary of name-value pairs for the properties, and then I wanted to test that the proper NLog target and logger was configured (out of the gate I wanted to support a database target and a mail target).

After some back and forth – some of which was me getting comfortable with the pattern, and some of which was me letting the tests drive the functionality that I needed – I arrived at the following structure:

private class NLogSettingsBuilder
{
    private List<KeyValuePair<String, String>> _Settings;

    public NLogSettingsBuilder()
    {
        this._Settings = new List<KeyValuePair<String, String>>();
    }

    public NLogSettingsBuilder WithAllDatabaseSettings()
    {
        this._Settings = new List<KeyValuePair<String, String>>();
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.connectionStringName", "SomeName"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.commandText", "insert into EventLog ([Origin], [LogLevel], [Message], [StackTrace], SourceMachine) values (@origin, @logLevel, @message, @stackTrace, @sourceMachine);"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.name", "database"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.Parameter.origin", "Services"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.Parameter.logLevel", "${level:uppercase=true}"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.Parameter.message", "${message}"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.Parameter.stackTrace", "${stacktrace}"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Database.Parameter.sourceMachine", "${machinename}"));

        this._Settings.Add(new KeyValuePair<String, String>("Logger.Database.minlevel", "Error"));
        return this;
    }

    public NLogSettingsBuilder WithAllMailSettings()
    {
        this._Settings = new List<KeyValuePair<String, String>>();
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.name", "email"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.subject", "Blah Local Dev Error"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.to", "mgilbert@blah.com"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.from", "nlog@blah.com"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.smtpServer", "smtp.blah.com"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.encoding", "UTF-8"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.body", "Timestamp: ${longdate}${newline}Level: ${level:uppercase=true}${newline}Logger: ${logger}${newline}Machine Name: ${machinename}${newline}${newline}Message: ${message}${newline}${newline}Stacktrace: ${stacktrace}"));
        this._Settings.Add(new KeyValuePair<String, String>("Target.Mail.html", "true"));

        this._Settings.Add(new KeyValuePair<String, String>("Logger.Mail.minlevel", "Error"));
        return this;
    }

    public NLogSettingsBuilder WithoutSetting(String Key)
    {
        this._Settings.RemoveAll(setting => setting.Key == Key);
        return this;
    }

    public NLogSettingsBuilder WithThisSettingAltered(String Key, String NewValue)
    {
        this.WithoutSetting(Key);
        this._Settings.Add(new KeyValuePair<String, String>(Key, NewValue));
        return this;
    }

    public Dictionary<String, String> Build()
    {
        Dictionary<String, String> NewSettings = new Dictionary<String, String>();
        if (this._Settings != null)
        {
            foreach (KeyValuePair<String, String> CurrentPair in this._Settings) { NewSettings.Add(CurrentPair.Key, CurrentPair.Value); }
        }
        return NewSettings;
    }
}

That allowed me to test things like:

this._NLogSettings = (new NLogSettingsBuilder())

                               .Build();

The default case, where there are no properties configured, and therefore no NLog targets will be configured.

this._NLogSettings = (new NLogSettingsBuilder())

                               .WithAllDatabaseSettings()

                               .Build();

All of the correct database settings will be present, so I should expect the database target and logger to be configured.

this._NLogSettings = (new NLogSettingsBuilder())

                               .WithAllMailSettings()

                               .Build();

All of the correct mail settings will be present, so I should expect the mail target and logger to be configured.

this._NLogSettings = (new NLogSettingsBuilder())

                               .WithAllDatabaseSettings()

                               .WithoutSetting("Target.Database.connectionStringName")

                               .Build();

All of the correct database settings will be present except for "Target.Database.connectionStringName".  Since this is a required property, I should not expect the database target and logger to be configured.

this._NLogSettings = (new NLogSettingsBuilder())

                               .WithAllDatabaseSettings()

                               .WithThisSettingAltered("Target.Database.connectionStringName", "Blah")

                               .Build();

All of the correct database settings will be present, and "Target.Database.connectionStringName" will have the value of "Blah".  I should not expect the database target and logger to be configured, and I should be able to test the connectionStringName property and confirm that its value matches "Blah".

As I said before, as soon as I read this, I was hooked.  I’ve struggled in the past to keep my test data manageable, and have had to bit a lot of bullets to make sweeping changes to keep it up to date.  This kind of pattern will help that immensely, and will go a great way, I think, to keeping the digital woodpeckers at bay.

Advertisements

April 23, 2014 - Posted by | Visual Studio/.NET

Sorry, the comment form is closed at this time.

%d bloggers like this: