Yes, it's possible to test a WinForms app... using MVP

Full article

If you find yourself in a position where you're supporting a WinForms application, you're likely to notice the tests... or lack thereof. Just because we may not have been so focused on automated tests and continuous integration when WinForms was younger, that doesn't mean we can't introduce them now. Better late than never!

Let's say you had an absurdly simple Form, like this one. It has 3 fields boxes to enter values (why? I dunno, it's pre-beta!), and an ADD button to, well you know, add them in the bottom box. The "Running Total" field never resets, but just keeps adding each total as long as the app is running.

Assume the above is implemented like this.. a relatively short bit of code. Too bad none of these methods can take advantage of automated testing. You'd need an instance of the Form itself, and every method is accessing or otherwise updating UI components. That won't do!

public partial class CalcForm : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private void btnAdd_Click(object sender, EventArgs e)
    {
        decimal total = 0;
        
        total += SafeGetNumber(txtNumber1);
        total += SafeGetNumber(txtNumber2);
        total += SafeGetNumber(txtNumber3);
        
        txtTotal.Text = total.ToString();
        txtRunningTotal.Text = SafeGetNumber(txtTotal) + total;
    }

    private void btnReset_Click(object sender, EventArgs e)
    {
        txtNumber1.Text = txtNumber2.Text = txtNumber3.Text = txtTotal.Text = "";

        txtNumber1.Focus();
    }
    
    private decimal SafeGetNumber(TextBox tb)
    {
    	return decimal.TryParse(tb.Text, out decimal res) ? res : 0;
    }
}

What is MVP?

In a nutshell, it's one of many frameworks (MVP, MVC, MVVM, etc) that all try to do the same thing - separate the UI from the business logic. There are multiple reasons for this, but right now I'm focusing on the fact it makes it easier to test the business logic.

MVP achieves this in 3 parts - a View, a Presenter, and a Model... and some interfaces thrown in for good measure. A quick disclaimer first - no doubt there are more ways to implement MVP than what I'm about to present, but keep in mind the end goal. We want to separate the UI from most of the rest of the code.

NOTE: If you want to try this out yourself, get the code from GitHub.

The View

The "View" represents the Form itself, and it includes an interface that represents everything you might need to get from (or set to) the Form, which is then used by the "Presenter" (more on that later) to tell it what to display next. Whereas before the View (your Form) had all the code neatly tucked away inside it, it's now very bare.

Here's how I converted the Form. Note that it's actually doing nothing intelligent now, other than wiring up all the UI components to various properties of the interface. Also note that I took the button click event handlers out of the designer file (where they automatically get created), and made those part of the interface as well. When a button's clicked, the Presenter will be know about it, and can act on it.

public interface ICalcView
{
    event EventHandler Add;
    event EventHandler Reset;
    string Value1 { get; set; }
    string Value2 { get; set; }
    string Value3 { get; set; }
    string Total { set; }
    string RunningTotal { set; }
    void Show();
}

public partial class CalcForm : Form, ICalcView
{
    public event EventHandler Add;
    public event EventHandler Reset;

    public CalcForm()
    {
        InitializeComponent();

        btnAdd.Click += delegate { Add?.Invoke(this, EventArgs.Empty); };
        btnReset.Click += delegate
        {
            Reset?.Invoke(this, EventArgs.Empty);
            txtNumber1.Focus();
        };
    }

    string ICalcView.Value1
    {
        get => txtNumber1.Text;
        set => txtNumber1.Text = value;
    }
    string ICalcView.Value2
    {
        get => txtNumber2.Text;
        set => txtNumber2.Text = value;
    }
    string ICalcView.Value3
    {
        get => txtNumber3.Text;
        set => txtNumber3.Text = value;
    }

    public string Total
    {
        set => txtTotal.Text = value;
    }
    public string RunningTotal
    {
        set => txtRunningTotal.Text = value;
    }
}

The Model

The "Model" represents some object that you're operating on. In my case, I made the Model a sort of calculator object that stores the totals and does the actual summing up. What you put in here is a bit subjective, but just keep the end goal in mind.

public interface ICalcModel
{
    decimal Total { get; }
    decimal RunningTotal { get; }
    void CalculateTotal(List<decimal> numbers);
}

public class CalcModel : ICalcModel
{
    public decimal Total { get; private set; }
    public decimal RunningTotal { get; private set; }

    public void CalculateTotal(List<decimal> numbers)
    {
        Total = numbers.Sum();
        RunningTotal += Total;
    }
}

The Presenter

So far, we've got a View that displays nothing, and a Model that stores numbers but can't do much else. What's the glue that ties them together? I present.. the Presenter!

The Presenter doesn't have an interface, at least not the way I designed it. But it does accept the interfaces that the View and Model implement, and it operates on those. It orchestrates everything, subscribing to events in the View, getting data from the View, passing that data to the Model, and moving things back and forth as needed.

Note that it doesn't actually touch the UI though.. just passes things back to the View, which pops it into the UI where the user can see it.

public class CalcPresenter
{
    readonly ICalcView view;
    readonly ICalcModel model;

    public CalcPresenter(ICalcView view = null, ICalcModel model = null)
    {
        this.view = view;
        this.model = model;
        this.view.Add += Add;
        this.view.Reset += Reset;
        this.view.Show();
    }

    public void Add(object sender, EventArgs e)
    {
        model.CalculateTotal(new List<string> { view.Value1, view.Value2, view.Value3 }.ConvertAll(TryGetNumber));

        view.Total = Convert.ToString(model.Total);
        view.RunningTotal = Convert.ToString(model.RunningTotal);
    }

    public void Reset(object sender, EventArgs e)
    {
        view.Value1 = view.Value2 = view.Value3 = view.Total = "";
    }

    public decimal TryGetNumber(string input)
    {
        return decimal.TryParse(input, out decimal res) ? res : 0;
    }
}

Why should I care?

"Ugh", you might be thinking. "This is sooOOooOOooOo much longer than before", you might be thinking. You're right, it is. But it's also a lot more intentional, and a lot more separated out. And it allows us to mock the interfaces and thoroughly test the logic in the Presenter and Model, like this.

[TestFixture]
public class CalcPresenterTests
{
    Mock<ICalcView> mockView;
    Mock<ICalcModel> mockModel;
    CalcPresenter presenter;

    [SetUp]
    public void Setup()
    {
        mockModel = new Mock<ICalcModel>();
        mockView = new Mock<ICalcView>();
        presenter = new CalcPresenter(mockView.Object, mockModel.Object);
    }

    [Test]
    public void AddTest()
    {
        mockView.SetupGet(x => x.Value1).Returns("10");
        mockView.SetupGet(x => x.Value2).Returns("20");
        mockView.SetupGet(x => x.Value3).Returns("30");
        mockModel.SetupGet(x => x.Total).Returns(60m);
        mockModel.SetupGet(x => x.RunningTotal).Returns(100m);

        presenter.Add(null, null);

        mockModel.Verify(x => x.CalculateTotal(It.IsAny<List<decimal>>()), Times.Once);
        mockView.VerifySet(x => x.Total = "60", Times.Once);
        mockView.VerifySet(x => x.RunningTotal = "100", Times.Once);
    }

    [Test]
    public void ResetTest()
    {
        presenter.Reset(null, null);

        mockView.VerifySet(x => x.Value1 = "", Times.Once);
        mockView.VerifySet(x => x.Value2 = "", Times.Once);
        mockView.VerifySet(x => x.Value3 = "", Times.Once);
        mockView.VerifySet(x => x.Total = "", Times.Once);
        mockView.VerifySet(x => x.RunningTotal = It.IsAny<string>(), Times.Never);
    }

    [Test]
    [TestCase("3", 3)]
    [TestCase("-3.22", -3.22)]
    [TestCase("0", 0)]
    [TestCase("", 0)]
    [TestCase("bad input!!", 0)]
    public void TryGetNumberReturnsExpectedValue(string input, decimal output)
    {
        Assert.AreEqual(output, presenter.TryGetNumber(input));
    }
}

[TestFixture]
public class CalcModelTests
{
    CalcModel model;

    [SetUp]
    public void Setup()
    {
        model = new CalcModel();
    }

    [Test]
    [TestCase(-13, -1, -1, -1, -10)]
    [TestCase(0, 0, 0, 0)]
    [TestCase(15, 1, 2, 3, 4, 5)]
    public void AddingNumbersGeneratesExpectedTotal(decimal expectedTotal, params int[] inputs)
    {
        model.CalculateTotal(inputs.Select(Convert.ToDecimal).ToList());

        Assert.AreEqual(expectedTotal, model.Total);
    }

    [Test]
    public void AddingNumbersTwiceRetainsLastOnly()
    {
        model.CalculateTotal(new List<decimal> { 1, 2, 3 });
        model.CalculateTotal(new List<decimal> { 10, 20, 30 });

        Assert.AreEqual(60, model.Total);
    }

    [Test]
    public void AddingNumbersTwiceIncreasesRunningTotal()
    {
        model.CalculateTotal(new List<decimal> { 1, 2, 3 });
        Assert.AreEqual(6, model.RunningTotal);

        model.CalculateTotal(new List<decimal> { 10, 20, 30 });
        Assert.AreEqual(66, model.RunningTotal);
    }
}

The end result? The beginnings of an automated test suite! You can plug this into TeamCity, Jenkins, or another CI tool and begin to get automated test runs. Yes, this is a lot more difficult in a large app that's been around for years, but with effort it's absolutely doable, one step at a time.

Author

Grant Winney

I write when I've got something to share - a personal project, a solution to a difficult problem, or just an idea. We learn by doing and sharing. We've all got something to contribute.


Comments / Reactions