Introduction
Many people will answer the question "what should you unit test in your project" with the flippant answer, "everything". My answer to those people is that's great if you have infinite budget and time. Unfortunately, I never seem to have either of those things. Especially building web sites, it's hard to test everything (though I agree completely possible). In this article I'm going to show an example of a problem I had and how unit testing was clearly the right solution.
I'll first explain the problem, then talk about how I refactored my code to allow for unit testing, then I'll show the actual tests.
The Problem
The problem is this. We are building a new conference web site with Visual Studio 2013 and plan on pricing based on two things. The first is how far out the conference is, and the second is how many people have signed up already. That is, we have a pricing table that looks like the following:
Price Good Through | Max Tickets To Sell Through this Price | Price |
1/15/2014 | 10 | $425 |
2/15/2014 | 25 | $495 |
3/24/2014 | 95 | $595 |
Seems straight forward enough, but the if you think through the details, what we really want to do is create a function that takes in this table of price data, takes a given date (like today) and then tells you some answers. Those answers are as follows:
What Is the Current Price?
- How Many Days Until The Price Increases?
- What Is the Date of the Next Price Increase?
- How Many Tickets Left at the Current Price?
- How Much is the Next Price Going to Be?
- What Is Full Price For This Ticket?
You can imagine now that a lot could go wrong in writing the simple program to calculate the above results. There are literally millions of possible inputs and outputs. What we will need to do is make some real world examples and then vary both the "today" date as well as how many tickets are already sold at the given attendance levels. All of a sudden, not so simple.
The Refactor
Let's now take a look at the current project that does this work (before we create any unit tests). Basically, we have in our business layer code the looks like this:
if (query.WithPricing.HasValue && query.WithPricing.Value) { // put the current payment status into the session var sessionPayment = sessionAttendeeResults.FirstOrDefault(a => a.Sessions_id == session.Id);// get the current pricing for the session and set it var sessionPriceList = sessionPriceResults.Where(a => a.SessionId == session.Id) .OrderBy(a => a.PriceGoodThroughDate) .ToList(); CalcSessionPricesAndNextPrice(session, sessionPriceList,DateTime.Now);</pre>
I realize it's just a chunk of the real project, but you get the idea. We are reading from the database a price matrix for a given session, then, we are calling a local method "CalcSessionPricesAndNextPrice" with our session record (which will get all the results) and the price list. We are passing in DateTime.Now so we get this answer based on right now (are we 60 days before the event for example).
So, what we are going to want to unit test is the "CalcSessionPricesAndNextPrice" method with lots of different parameters. In my case, I'm actually going to write "CalcSessionPricesAndNextPrice" as I build my tests. Some would say you should build all your tests first, then write the method and run all those tests, but my preference is to take little steps. First write a couple tests, then write the method that solves that. Repeat over and over until I feel I have a full compliment of tests.
The Unit Test Project Setup
Now we can create a unit test project. I won't go through in Visual Studio 2013 the screens to get there. I assume you know how to do File/Create Project/Create C# Unit Test. You can see in the screen shot below what I've created. I always append my test project with .Test so it's clear what project I'm testing.
You can see this is our web solution. Previously we had DataAccess and now we have DataAccess.Test with one unit test (SessionPriceGoodThroughTest.cs).
The Unit Tests Themselves
Let's take a look now at the unit tests thenselves in the file SessionPriceGoodThroughTest.cs. Basically, We've created 4 different test methods in our SessionPriceGoodThroughTests class. One method, InitSessionDataForPricing basically gives some sample data that we will be testing on. I've collapsed then below so you can see a screen shot giving some sense to our explanation.
And, the Init Method Expanded:
private static void InitSessionDataForPricing(out SessionsResult sessionsResult, out List<SessionPriceResult> sessionPriceResults) { sessionsResult = new SessionsResult { Id = 1 };sessionPriceResults = new List<SessionPriceResult> { new SessionPriceResult { Id = 1, PriceOfSession = 100.00M, MaxAtThisPrice = 1000, PriceGoodThroughDate = new DateTime(2010, 2, 1) }, new SessionPriceResult { Id = 1, PriceOfSession = 250.00M, MaxAtThisPrice = 1000, PriceGoodThroughDate = new DateTime(2010, 2, 5) }, new SessionPriceResult { Id = 1, PriceOfSession = 500.00M, MaxAtThisPrice = 1000, PriceGoodThroughDate = new DateTime(2010, 2, 10) } }; }
}
What we will do is build a matrix of tests covering ranges of both effective dates and current attendance. Then, we will do asserts to make sure that our results are what we expect. Let me next show just a few of these test methods. I think you will get the idea of what they do from the examples.
[TestMethod] public void TestSessionPriceCalcCurrentAttendanceEffectiveDateBeforeAllPrices() { SessionsResult sessionsResult; List<SessionPriceResult> sessionPriceResults; InitSessionDataForPricing(out sessionsResult, out sessionPriceResults);sessionPriceResults[0].MaxAtThisPrice = 5; // 5 people, price through 2/5 sessionPriceResults[1].MaxAtThisPrice = 15; // 15 people, price through 2/10 // does not matter final [2] because this is everyone else var effDate = new DateTime(2010, 1, 30); sessionsResult.CurrentAttendance = 0; SessionsManager.CalcSessionPricesAndNextPrice(sessionsResult, sessionPriceResults, effDate); Assert.AreEqual(sessionsResult.CurrentPrice, "100.00"); Assert.AreEqual(sessionsResult.DaysUntilNextPrice, 2); Assert.AreEqual(sessionsResult.NextPrice, "250.00"); Assert.AreEqual(sessionsResult.NextPriceDate, "2/5/2010"); Assert.AreEqual(sessionsResult.SignupsUntilNextPrice,5);</pre>
and, with a little extra automation around varying the number attending each session:
[TestMethod] public void TestSessionPriceCalcCurrentAttendanceEffectiveDateAfterAllPrices() { SessionsResult sessionsResult; List<SessionPriceResult> sessionPriceResults; InitSessionDataForPricing(out sessionsResult, out sessionPriceResults);var effDate = new DateTime(2010, 2, 18); sessionPriceResults[0].MaxAtThisPrice = 5; // 5 people, price through 2/5 sessionPriceResults[1].MaxAtThisPrice = 15; // 15 people, price through 2/10 // does not matter final [2] because this is everyone else // all prices should return the same, no matter what attendance var testAttendance = new List<int> {0,1,4,5,6,11,14,15,16,1000}; foreach (var testNumber in testAttendance) { sessionsResult.CurrentAttendance = testNumber; SessionsManager.CalcSessionPricesAndNextPrice(sessionsResult, sessionPriceResults, effDate); Assert.AreEqual(sessionsResult.CurrentPrice, "500.00"); Assert.AreEqual(sessionsResult.DaysUntilNextPrice, 0); Assert.AreEqual(sessionsResult.NextPrice, "500.00"); Assert.AreEqual(sessionsResult.NextPriceDate, "2/10/2010"); Assert.AreEqual(sessionsResult.SignupsUntilNextPrice, 0); }
}
Running The Unit Tests
I personally use ReSharper for running unit tests. I like the interface so that is what I'm going to show. You can see that all my tests have run successfully in the picture below.
Wrap Up
I think you can see that in this case, having about 50 different unit tests really helps to flesh out that our method works. I'm 100% sure this test saved time in writing the function that does the work (which I'll paste below just so you have an idea of what I'm testing, as well as gives me confidence now and in the future that it all really works.
/// <summary> /// process the SessionPriceList Based on a passed in date and figures out new prices /// </summary> /// <param name="session"></param> /// <param name="sessionPriceList"></param> /// <param name="effectiveDate"></param> public static void CalcSessionPricesAndNextPrice( SessionsResult session, List<SessionPriceResult> sessionPriceList, DateTime effectiveDate) { if (sessionPriceList.Count == 1) { session.RetailPrice = sessionPriceList[0].PriceOfSession.ToString("F2"); session.NextPrice = session.RetailPrice; session.CurrentPrice = session.RetailPrice; return; }// assume retail price first CalculateRetailPrice(session, sessionPriceList); // look for first good price (effective now price). If none found, then assume retail price // this basically spins through price records until it hits one that is effective and not oversold. for (int index = 0; index < sessionPriceList.Count; index++) { // check to see if this is the final price. If so, then use retail price which is default if (index == sessionPriceList.Count-1) { CalculateRetailPrice(session, sessionPriceList); break; } var sessionPrice = sessionPriceList[index]; if (sessionPrice.PriceGoodThroughDate.CompareTo(effectiveDate) > 0) { session.CurrentPrice = sessionPrice.PriceOfSession.ToString("F2"); if (index < sessionPriceList.Count - 1) { var nextOne = sessionPriceList[index + 1]; session.NextPriceDate = nextOne.PriceGoodThroughDate.ToString("d"); session.NextPrice = nextOne.PriceOfSession.ToString("F2"); session.DaysUntilNextPrice = sessionPrice.PriceGoodThroughDate.Subtract(effectiveDate).Days; } // confirm that we have number at this slot (if null, then always stop at this price) if (!sessionPrice.MaxAtThisPrice.HasValue) { // no value for max a this price so we assume infinite can be sold at this price break; } // MaxAtThisPrice has value so figure out if we have exceeded it session.SignupsUntilNextPrice = sessionPrice.MaxAtThisPrice.Value - session.CurrentAttendance; if (session.SignupsUntilNextPrice > 0) { // stop going through records because we have both an // effective date (guaranteed because we made it through if above) // and we have room at this price level break; } else { } } }
}