How test-driven development ensured our tax calculator always adheres to all of the Amazon Laws for online orders
When it comes to collecting tax on online orders, the rules that merchants have to abide by these days are complicated. Due to a set of laws being called the Amazon Laws, whether or not an online order is taxable or not depends on a number of factors, including destination, shipping origin, and tax nexus. And because these laws are imposed at the state level, each state has a different set of rules to follow. It gets even more complex when an order has to ship over state lines.
In the early days of our eCommerce platform at Weebly, we simply had a system to manually enter in the tax rates you want to apply and where to apply them. This worked fine, but it left it up to our users to research for themselves how they should be charging tax, and help with setting up taxes became one of our largest drivers of support requests. So we wanted to do better, and that meant building a system that can automatically calculate taxes on orders.
This created a very interesting software design problem and, in this post, I will be doing a deep dive into the technical approach and implementation that went into creating the automatic tax calculator. In addition to covering the topic of online sales tax, it will serve as a case study for approaching a problem with a test driven development (TDD) mindset.
United States Sales Tax Laws
Before getting into the technical approach, it is worthwhile to quickly go over the sales tax laws that our service will have to abide by.
To help visualize all of the above rules, a flowchart has been created and can be seen in Figure 1.
Figure 1 — Flowchart to correctly determine the tax on an order within the United States
Whenever writing software to be used as a service such as this tax calculator, considering what the interface to your service will look like can be a huge influence in the design of your software. It will also help us create our tests. In this case, we are interested in taking an order, an origin, and a destination, and then returning a tax rate to apply to the order. In other words, something like this:
There is our basic use case. However, it does not yet include all of the information we need to properly calculate the tax. As was explained above, we also need the tax nexus. To handle this, we can construct an instance of the tax calculator that takes in the addresses to use as the nexus.
This initial pass is not meant to perfect. For instance, we are eventually going to want to have a standard address format, as well as a way to lookup tax rates. But for now, this gives us an idea of how our service is going to be used.
Defining the Test Cases
At this point, we have a flowchart to implement and a rough idea of the interface to our service. This is enough information to define some test cases so that we can develop in a test-driven manner.
In order to have 100% test coverage, we should test each possible path in the flowchart. To keep this simple, lets exclude the California special case. That gives us the following 9 paths to test:
We know this will be 100% test coverage because there are four binary decisions in our flowchart. This gives us 2⁴, or 16, test cases. However, since the first decision has a termination case, we only need to test it one time, resulting in 7 of the possible permutations being short-circuited. Thus leaving us with 16–7 = 9 cases.
Now, using the same interface as we saw above, we can write our tests.
If you are not familiar with the way PHP unit works, this will simply take the test cases provided by the dataProvider and run them through the testTaxCalculatorRates function to see if the returned rate is equal to the expected rate.
With all of our test cases written out, we can run the tests and, as you might expect, they will all fail. This is a good thing! It means we are ready to begin development of a system that will satisfy all of the cases we just defined.
Conclusion and Learnings
There is an ongoing holy war about the effectiveness of TDD. Some think that it is a waste of time while others believe it is a sure-fire way to avoid bugs. I am not here to convince you one way or the other, but lets just take a look at where we are at in our development process.
Doing these things ahead of time gives us the confidence that the code we are writing will satisfy all requirements of the system. Whether or not you are a TDD believer, we can all agree tests are useful — especially when it comes to charging money which comes along with legal liabilities. It is not something we want to break even for a few minutes. Therefore, having these tests from the start is immeasurably helpful.
Where TDD falls short, however, is that it is sometimes (if not often) impossible to foresee how the software design will change over the course of the implementation. This results in having to tweak our tests as we implement the system. In part 2 of this post, I will be covering the actual implementation of our system and we will see how the basic interface will evolve to better adhere to good programming practices, such as using design patterns for code organization and dependency injection for data layer abstraction.