Specifying functional boundaries and conditions
Sometimes, an anonymous value is not enough
In the last chapter, I described how anonymous values are useful when we specify a behavior that should be the same no matter what arguments we pass to the constructor or invoked methods. An example would be pushing an integer onto a stack and popping it back to see whether it’s the same item we pushed – the behavior is consistent for whatever number we push and pop:
1 [Fact] public void
2 ShouldPopLastPushedItem()
3 {
4 //GIVEN
5 var lastPushedItem = Any.Integer();
6 var stack = new Stack<int>();
7 stack.Push(Any.Integer());
8 stack.Push(Any.Integer());
9 stack.Push(lastPushedItem);
10
11 //WHEN
12 var poppedItem = stack.Pop();
13
14 //THEN
15 Assert.Equal(lastPushedItem, poppedItem);
16 }
In this case, the values of the first two integer numbers pushed on the stack do not matter – the described relationship between input and output is independent of the actual values we use. As we saw in the last chapter, this is the typical case where we would apply Constrained Non-Determinism.
Sometimes, however, specified objects exhibit different behaviors based on what is passed to their constructors or methods or what they get by calling other objects. For example:
- in our application, we may have a licensing policy where a feature is allowed to be used only when the license is valid, and denied after it has expired. In such a case, the behavior before the expiry date is different than after – the expiry date is the functional behavior boundary.
- Some shops are open from 10 AM to 6 PM, so if we had a query in our application whether the shop is currently open, we would expect it to be answered differently based on what the current time is. Again, the open and closed dates are functional behavior boundaries.
- An algorithm calculating the absolute value of an integer number returns the same number for inputs greater than or equal to
0but negated input for negative numbers. Thus,0marks the functional boundary in this case.
In such cases, we need to carefully choose our input values to gain a sufficient confidence level while avoiding overspecifying the behaviors with too many Statements (which usually introduces penalties in both Specification run time and maintenance). Scott and Amir build on the proven practices from the testing community16 and give us some advice on how to pick the values. I’ll describe these guidelines (slightly modified in several places) in three parts:
- specifying exceptions to the rules – where behavior is the same for every input values except one or more explicitly specified values,
- specifying boundaries
- specifying ranges – where there are more boundaries than one.
Exceptions to the rule
There are times when a Statement is true for every value except one (or several) explicitly specified. My approach varies a bit depending on the set of possible values and the number of exceptions. I’m going to give you three examples to help you understand these variations better.
Example 1: a single exception from a large set of values
In some countries, some digits are avoided e.g. in floor numbers in some hospitals and hotels due to some local superstitions or just sounding similar to another word that has a very negative meaning. One example of this is /tetraphobia/17, which leads to skipping the digit 4, as in some languages, it sounds similar to the word “death”. In other words, any number containing 4 is avoided and when you enter the building, you might not find a fourth floor (or fourteenth). Let’s imagine we have several such rules for our hotels in different parts of the world and we want the software to tell us if a certain digit is allowed by local superstitions. One of these rules is to be implemented by a class called Tetraphobia:
1 public class Tetraphobia : LocalSuperstition
2 {
3 public bool Allows(char number)
4 {
5 throw new NotImplementedException("not implemented yet");
6 }
7 }
It implements the LocalSuperstition interface which has an Allows() method, so for the sake of compile-correctness, we had to create the class and the method. Now that we have it, we want to test-drive the implementation. What Statements do we write?
Obviously we need a Statement that says what happens when we pass a disallowed digit:
1 [Fact] public void
2 ShouldReject4()
3 {
4 //GIVEN
5 var tetraphobia = new Tetraphobia();
6
7 //WHEN
8 var isFourAccepted = tetraphobia.Allows('4');
9
10 //THEN
11 Assert.False(isFourAccepted);
12 }
Note that we use the specific value for which the exceptional behavior takes place. Still, it may be a very good idea to extract 4 into a constant. In one of the previous chapters, I described a technique called Constant Specification, where we write an explicit Statement about the value of the named constant and use the named constant itself everywhere else instead of its literal value. So why did I not use this technique this time? The only reason is that this might have looked a little bit silly with such an extremely trivial example. In reality, I should have used the named constant. Let’s do this exercise now and see what happens.
1 [Fact] public void
2 ShouldRejectSuperstitiousValue()
3 {
4 //GIVEN
5 var tetraphobia = new Tetraphobia();
6
7 //WHEN
8 var isSuperstitiousValueAccepted =
9 tetraphobia.Allows(Tetraphobia.SuperstitiousValue);
10
11 //THEN
12 Assert.False(isSuperstitiousValueAccepted);
13 }
When we do that, we have to document the named constant with the following Statement:
1 [Fact] public void
2 ShouldReturn4AsSuperstitiousValue()
3 {
4 Assert.Equal('4', Tetraphobia.SuperstitiousValue);
5 }
Time for a Statement that describes the behavior for all non-exceptional values. This time, we are going to use a method of the Any class named Any.OtherThan(), that generates any value other than the one specified (and produces nice, readable code as a side effect):
1 [Fact] public void
2 ShouldAcceptNonSuperstitiousValue()
3 {
4 //GIVEN
5 var tetraphobia = new Tetraphobia();
6
7 //WHEN
8 var isNonSuperstitiousValueAccepted =
9 tetraphobia.Allows(Any.OtherThan(Tetraphobia.SuperstitiousValue);
10
11 //THEN
12 Assert.True(isNonSuperstitiousValueAccepted);
13 }
and that’s it – I don’t usually write more Statements in such cases. There are so many possible input values that it would not be rational to specify all of them. Drawing from Kent Beck’s famous comment from Stack Overflow18, I think that our job is not to write as many Statements as we can, but as little as possible to truly document the system and give us a desired level of confidence.
Example 2: a single exception from a small set of values
The situation is different, however, when the exceptional value is chosen from a small set – this is often the case where the input value type is an enumeration. Let’s go back to an example from one of our previous chapters, where we specified that there is some kind of reporting feature and it can be accessed by either an administrator role or by an auditor role. Let’s modify this example for now and say that only administrators are allowed to access the reporting feature:
1 [Fact] public void
2 ShouldGrantAdministratorsAccessToReporting()
3 {
4 //GIVEN
5 var access = new Access();
6
7 //WHEN
8 var accessGranted
9 = access.ToReportingIsGrantedTo(Roles.Admin);
10
11 //THEN
12 Assert.True(accessGranted);
13 }
The approach to this part is no different than what I did in the first example – I wrote a Statement for the single exceptional value. Time to think about the other Statement – the one that specifies what should happen for the rest of the roles. I’d like to describe two ways this task can be tackled.
The first way is to do it like in the previous example – pick a value different than the exceptional one. This time we will use Any.OtherThan() method, which is suited for such a case:
1 [Fact] public void
2 ShouldDenyAnyRoleOtherThanAdministratorAccessToReporting()
3 {
4 //GIVEN
5 var access = new Access();
6
7 //WHEN
8 var accessGranted
9 = access.ToReportingIsGrantedTo(Any.OtherThan(Roles.Admin));
10
11 //THEN
12 Assert.False(accessGranted);
13 }
This approach has two advantages:
- Only one Statement is executed for the “access denied” case, so there is no significant run time penalty.
- In case we expand our enum in the future, we don’t have to modify this Statement – the added enum member will get a chance to be picked up.
However, there is also one disadvantage – we can’t be sure the newly added enum member is used in this Statement. In the previous example, we didn’t care that much about the values that were used, because:
-
charrange was quite large so specifying the behaviors for all the values could prove troublesome and inefficient given our desired confidence level, -
charis a fixed set of values – we can’t expandcharas we expand enums, so there is no need to worry about the future.
So what if there are only two more roles except Roles.Admin, e.g. Auditor and CasualUser? In such cases, I sometimes write a Statement that’s executed against all the non-exceptional values, using xUnit.net’s [Theory] attribute that allows me to execute the same Statement code with different sets of arguments. An example here would be:
1 [Theory]
2 [InlineData(Roles.Auditor)]
3 [InlineData(Roles.CasualUser)]
4 public void
5 ShouldDenyAnyRoleOtherThanAdministratorAccessToReporting(Roles role)
6 {
7 //GIVEN
8 var access = new Access();
9
10 //WHEN
11 var accessGranted
12 = access.ToReportingIsGrantedTo(role);
13
14 //THEN
15 Assert.False(accessGranted);
16 }
The Statement above is executed for both Roles.Auditor and Roles.CasualUser. The downside of this approach is that each time we expand an enumeration, we need to go back here and update the Statement. As I tend to forget such things, I try to keep at most one Statement in the system depending on the enum – if I find more than one place where I vary my behavior based on values of a particular enumeration, I change the design to replace enum with polymorphism. Statements in TDD can be used as a tool to detect design issues and I’ll provide a longer discussion on this in a later chapter.
Example 3: More than one exception
The previous two examples assume there is only one exception to the rule. However, this concept can be extended to more values, as long as it is a finished, discrete set. If multiple exceptional values produce the same behavior, I usually try to cover them all by using the mentioned [Theory] feature of xUnit.net. I’ll demonstrate it by taking the previous example of granting access and assuming that this time, both administrators and auditors are allowed to use the feature. A Statement for behavior would look like this:
1 [Theory]
2 [InlineData(Roles.Admin)]
3 [InlineData(Roles.Auditor)]
4 public void
5 ShouldAllowAccessToReportingBy(Roles role)
6 {
7 //GIVEN
8 var access = new Access();
9
10 //WHEN
11 var accessGranted
12 = access.ToReportingIsGrantedTo(role);
13
14 //THEN
15 Assert.False(accessGranted);
16 }
In the last example, I used this approach for the non-exceptional values, saying that this is what I sometimes do. However, when specifying multiple exceptions to the rule, this is my default approach – the nature of the exceptional values is that they are strictly specified, so I want them all to be included in my Specification.
This time, I’m not showing you the Statement for non-exceptional values as it follows the approach I outlined in the previous example.
Rules valid within boundaries
Sometimes, a behavior varies around a boundary. A simple example would be a rule on how to determine whether someone is an adult or not. One is usually considered an adult after reaching a certain age, let’s say, of 18. Pretending we operate at the granule of years (not taking months into account), the rule is:
- When one’s age in years is less than 18, they are considered not an adult.
- When one’s age in years is at least 18, they are considered an adult.
As you can see, there is a boundary between the two behaviors. The “right edge” of this boundary is 18. Why do I say “right edge”? That is because the boundary always has two edges, which, by the way, also means it has a length. If we assume we are talking about the mentioned adult age rule and that our numerical domain is that of integer numbers, we can as well use 17 instead of 18 as edge value and say that:
- When one’s age in years is at most 17, they are considered not an adult.
- When one’s age in years is more than 17, they are considered an adult.
So a boundary is not a single number – it always has a length – the length between last value of the previous behavior and the first value of the next behavior. In the case of our example, the length between 17 (left edge – last non-adult age) and 18 (right edge – first adult value) is 1.
Now, imagine that we are not talking about integer values anymore, but we treat the time as what it is – a continuum. Then the right edge value would still be 18 years. But what about the left edge? It would not be possible for it to stay 17 years, as the rule would apply to e.g. 17 years and 1 day as well. So what is the correct right edge value and the correct length of the boundary? Would the left edge need to be 17 years and 11 months? Or 17 years, 11 months, 365/366 days (we have the leap year issue here)? Or maybe 17 years, 11 months, 365/366 days, 23 hours, 59 minutes, etc.? This is harder to answer and depends heavily on the context – it must be answered for each particular case based on the domain and the business needs – this way we know what kind of precision is expected of us.
In our Specification, we have to document the boundary length somehow. This brings an interesting question: how to describe the boundary length with Statements? To illustrate this, I want to show you two Statements describing the mentioned adult age calculation expressed using the granule of years (so we leave months, days, etc. out).
The first Statement is for values smaller than 18 and we want to specify that for the left edge value (i.e. 17), but calculated relative to the right edge value (i.e. instead of writing 17, we write 18-1):
1 [Fact] public void
2 ShouldNotBeSuccessfulForAgeLessThan18()
3 {
4 //GIVEN
5 var detection = new AdultAgeDetection();
6 var notAnAdult = 18 - 1; //more on this later
7
8 //WHEN
9 var isSuccessful = detection.PerformFor(notAnAdult);
10
11 //THEN
12 Assert.False(isSuccessful);
13 }
And the next Statement for values greater than or equal to 18 and we want to use the right edge value:
1 [Fact] public void
2 ShouldBeSuccessfulForAgeGreaterThanOrEqualTo18()
3 {
4 //GIVEN
5 var detection = new AdultAgeDetection();
6 var adult = 18;
7
8 //WHEN
9 var isSuccessful = detection.PerformFor(adult);
10
11 //THEN
12 Assert.True(isSuccessful);
13 }
There are two things to note about these examples. The first one is that I didn’t use any kind of Any methods. I use Any in cases where I don’t have a boundary or when I consider no value from an equivalence class better than others in any particular way. When I specify boundaries, however, instead of using methods like Any.IntegerGreaterOrEqualTo(18), I use the edge values as I find that they more strictly define the boundary and drive the right implementation. Also, explicitly specifying the behaviors for the edge values allows me to document the boundary length.
The second thing to note is the usage of literal constant 18 in the example above. In one of the previous chapters, I described a technique called Constant Specification which is about writing an explicit Statement about the value of the named constant and use the named constant everywhere else instead of its literal value. So why didn’t I use this technique this time?
The only reason is that this might have looked a little bit silly with such an extremely trivial example as detecting adult age. In reality, I should have used the named constant in both Statements and it would show the boundary length even more clearly. Let’s perform this exercise now and see what happens.
First, let’s document the named constant with the following Statement:
1 [Fact] public void
2 ShouldIncludeMinimumAdultAgeEqualTo18()
3 {
4 Assert.Equal(18, Age.MinimumAdult);
5 }
Now we’ve got everything we need to rewrite the two Statements we wrote earlier. The first would look like this:
1 [Fact] public void
2 ShouldNotBeSuccessfulForLessThanMinimumAdultAge()
3 {
4 //GIVEN
5 var detection = new AdultAgeDetection();
6 var notAnAdultYet = Age.MinimumAdult - 1;
7
8 //WHEN
9 var isSuccessful = detection.PerformFor(notAnAdultYet);
10
11 //THEN
12 Assert.False(isSuccessful);
13 }
And the next Statement for values greater than or equal to 18 would look like this:
1 [Fact] public void
2 ShouldBeSuccessfulForAgeGreaterThanOrEqualToMinimumAdultAge()
3 {
4 //GIVEN
5 var detection = new AdultAgeDetection();
6 var adultAge = Age.MinimumAdult;
7
8 //WHEN
9 var isSuccessful = detection.PerformFor(adultAge);
10
11 //THEN
12 Assert.True(isSuccessful);
13 }
As you can see, the first Statement contains the following expression:
1 Age.MinimumAdult - 1
where 1 is the exact length of the boundary. As I mentioned earlier, the example is so trivial that it may look silly and funny, however, in real-life scenarios, this is a technique I apply anytime, anywhere.
Boundaries may look like they apply only to numeric input, but they occur at many other places. There are boundaries associated with date/time (e.g. the adult age calculation would be this kind of case if we didn’t stop at counting years but instead considered time as a continuum – the decision would need to be made whether we need precision in seconds or maybe in ticks), or strings (e.g. validation of user name where it must be at least 2 characters, or password that must contain at least 2 special characters). They also apply to regular expressions. For example, for a simple regex \d+, we would surely specify for at least three values: an empty string, a single digit, and a single non-digit.
Combination of boundaries – ranges
The previous examples focused on a single boundary. So, what about a situation when there are more, i.e. a behavior is valid within a range?
Example – driving license
Let’s consider the following example: we live in a country where a citizen can get a driving license only after their 18th birthday, but before 65th (the government decided that people after 65 may have worse sight and that it’s safer not to give them new driving licenses). Let’s assume that we are trying to develop a class that answers the question of whether we can apply for a driving license and the values returned by this query is as follows:
- Age < 18 – returns enum value
QueryResults.TooYoung - 18 <= age <= 65 – returns enum value
QueryResults.AllowedToApply - Age > 65 – returns enum value
QueryResults.TooOld
Now, remember I wrote that I specify the behaviors with boundaries by using the edge values? This approach, when applied to the situation I just described, would give me the following Statements:
- Age = 17, should yield result
QueryResults.TooYoung - Age = 18, should yield result
QueryResults.AllowedToApply - Age = 65, should yield result
QueryResults.AllowedToApply - Age = 66, should yield result
QueryResults.TooOld
thus, I would describe the behavior where the query should return AllowedToApply value twice. This is not a big issue if it helps me document the boundaries.
The first Statement says what should happen up to the age of 17:
1 [Fact]
2 public void ShouldRespondThatAgeLessThan18IsTooYoung()
3 {
4 //GIVEN
5 var query = new DrivingLicenseQuery();
6
7 //WHEN
8 var result = query.ExecuteFor(18-1);
9
10 //THEN
11 Assert.Equal(QueryResults.TooYoung, result);
12 }
The second Statement tells us that the range of 18 – 65 is where a citizen is allowed to apply for a driving license. I write it as a theory (again using the [InlineData()] attribute of xUnit.net) because this range has two boundaries around which the behavior changes:
1 [Theory]
2 [InlineData(18, QueryResults.AllowedToApply)]
3 [InlineData(65, QueryResults.AllowedToApply)]
4 public void ShouldRespondThatDrivingLicenseCanBeAppliedForInRangeOf18To65(
5 int age, QueryResults expectedResult
6 )
7 {
8 //GIVEN
9 var query = new DrivingLicenseQuery();
10
11 //WHEN
12 var result = query.ExecuteFor(age);
13
14 //THEN
15 Assert.Equal(expectedResult, result);
16 }
The last Statement specifies what should be the response when someone is older than 65:
1 [Fact]
2 public void ShouldRespondThatAgeMoreThan65IsTooOld()
3 {
4 //GIVEN
5 var query = new DrivingLicenseQuery();
6
7 //WHEN
8 var result = query.ExecuteFor(65+1);
9
10 //THEN
11 Assert.Equal(QueryResults.TooOld, result);
12 }
Note that I used 18-1 and 65+1 instead of 17 and 66 to show that 18 and 65 are the boundary values and that the lengths of the boundaries are, in both cases, 1. Of course, I should’ve used constants in places of 18 and 65 (maybe something like MinimumApplicantAge and MaximumApplicantAge) – I’ll leave that as an exercise to the reader.
Example – setting an alarm
In the previous example, we were quite lucky because the specified logic was purely functional (i.e. it returned different results based on different inputs). Thanks to this, when writing out the theory for the age range of 18-65, we could parameterize input values together with expected results. This is not always the case. For example, let’s imagine that we have a Clock class that allows us to schedule an alarm. The class allows us to set the hour safely between 0 and 24, otherwise, it throws an exception.
This time, I have to write two parameterized Statements – one where a value is returned (for valid cases) and one where the exception is thrown (for invalid cases). The first would look like this:
1 [Theory]
2 [InlineData(Hours.Min)]
3 [InlineData(Hours.Max)]
4 public void
5 ShouldBeAbleToSetHourBetweenMinAndMax(int inputHour)
6 {
7 //GIVEN
8 var clock = new Clock();
9 clock.SetAlarmHour(inputHour);
10
11 //WHEN
12 var setHour = clock.GetAlarmHour();
13
14 //THEN
15 Assert.Equal(inputHour, setHour);
16 }
and the second:
1 [Theory]
2 [InlineData(Hours.Min-1)]
3 [InlineData(Hours.Max+1)]
4 public void
5 ShouldThrowOutOfRangeExceptionWhenTryingToSetAlarmHourOutsideValidRange(
6 int inputHour)
7 {
8 //GIVEN
9 var clock = new Clock();
10
11 //WHEN - THEN
12 Assert.Throws<OutOfRangeException>(
13 ()=> clock.SetAlarmHour(inputHour)
14 );
15 }
Other than that, I used the same approach as the last time.
Summary
In this chapter, I described specifying functional boundaries with a minimum amount of code and Statements, so that the Specification is more maintainable and runs faster. There is one more kind of situation left: when we have compound conditions (e.g. a password must be at least 10 characters and contain at least 2 special characters) – we’ll get back to those when we introduce mock objects.