Boundary Value Analysis: 2-Value and 3-Value BVA, Off-by-One, and Date, Money and Length Boundaries
Boundary value analysis picks test values at a partition's boundary and its neighbors rather than its middle; the type's and the rule's precision decide what a neighbor is. In Bookstore, with one green test per partition, free shipping at 500.00 TRY, a November campaign shifted to UTC and a last second closed at 23:59:59 were caught only by boundary tests. 2-value and 3-value BVA, date boundaries with FakeTimeProvider, ISBN length and cart size.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3 and Microsoft.Extensions.TimeProvider.Testing 11.0.0.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, has two new rules. If the discounted subtotal is 500 TRY or more, shipping is free. The November campaign runs from 1 November to 30 November; the back office screen takes the campaign as days, and the days are in Istanbul time. Emre's code:
namespace Bookstore.Shipping;
public static class FreeShippingPolicy
{
public const decimal Threshold = 500.00m;
public static bool Qualifies(decimal discountedSubtotal) => discountedSubtotal > Threshold;
}
namespace Bookstore.Catalog;
public sealed record Campaign(string Code, decimal Rate, DateTimeOffset StartsAt, DateTimeOffset EndsAt)
{
public static Campaign ForDays(string code, decimal rate, DateOnly firstDay, DateOnly lastDay) =>
new(code, rate,
new DateTimeOffset(firstDay.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
new DateTimeOffset(lastDay.ToDateTime(new TimeOnly(23, 59, 59)), TimeSpan.Zero));
public bool IsActiveAt(DateTimeOffset instant) => instant >= StartsAt && instant <= EndsAt;
}
And the tests:
private static readonly Campaign NovemberSale =
Campaign.ForDays("NOVEMBER25", 0.25m, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30));
[Theory]
[InlineData(250.00, false)]
[InlineData(750.00, true)]
public void Free_shipping_from_500_try(decimal subtotal, bool expected)
{
Assert.Equal(expected, FreeShippingPolicy.Qualifies(subtotal));
}
[Fact]
public void Campaign_is_active_in_november()
{
Assert.True(NovemberSale.IsActiveAt(new DateTimeOffset(2026, 11, 15, 12, 0, 0, TimeSpan.FromHours(3))));
Assert.False(NovemberSale.IsActiveAt(new DateTimeOffset(2026, 12, 10, 12, 0, 0, TimeSpan.FromHours(3))));
}
The tests are green. In the language of the previous post, there is one representative from each partition: "below 500" and "500 and above" for shipping, "during the campaign" and "after the campaign" for the campaign. From an equivalence partitioning point of view nothing is missing. How many bugs are in these two classes?
The Answer: The Bugs Sit at the Boundary
Three, and all three are at the boundary. FreeShippingPolicy uses >; a 500.00 TRY cart doesn't get free shipping. Campaign.ForDays turns the days into UTC days, so in Istanbul time the campaign starts on 1 November at 03:00 and ends on 1 December at 02:59:59. It also closes the end at 23:59:59, at the start of that second; every instant inside the last second falls outside the campaign.
The first of these bugs is what the literature calls an off-by-one error: > instead of >=, <= instead of <, 12 digits instead of 13. The two campaign bugs belong to the same family; one moves the boundary by three hours, the other by one second. These are also the typical defects the syllabus lists for BVA: a boundary placed above or below where it should be, or left out altogether.
What the three bugs have in common is that no value in the middle of a partition shows them. 250 and 750 TRY, noon on 15 November and noon on 10 December all take the right path through the code. The bug is where two partitions meet, where the code puts the boundary in the wrong place. Equivalence partitioning asks for one value from each partition but doesn't say which one. Boundary value analysis fills exactly that gap: pick the value at the boundary.
Boundary Values and Their Neighbors: 2-Value and 3-Value BVA
Boundary value analysis, or BVA, tests the edges of partitions. The smallest and largest values of a partition are its boundary values. There is a precondition: the partition must be ordered. Numbers, dates and lengths are ordered; if two values are in the same partition, every value between them is in that partition too. Enums such as customer type or payment method are not ordered; they have no boundaries, and BVA doesn't apply.
The ISTQB CTFL v4.0.1 syllabus defines two variants. In 2-value BVA each boundary value has two coverage items: the boundary value itself and the closest value in the adjacent partition. For free shipping those are 499.99 and 500.00. In 3-value BVA each boundary value has three coverage items: the boundary value and its neighbor on each side. For shipping, 499.98 and 500.01 join the set.
The syllabus's reason for 3-value goes like this: if a condition that should be x <= 10 is written as x == 10 by mistake, the two values of 2-value BVA (10 and 11) don't reveal the bug, while the 9 added by 3-value does. The last exercise in A Small Experiment reproduces this situation with Bookstore's minimum quantity rule.
"The closest value" depends on the precision of the type and of the business rule, and it's the most often skipped part of BVA. If an amount is precise to the kuruş, the step is 0.01 TRY. The step of DateTimeOffset is one tick, 100 nanoseconds. If the value is written to the database in a column with second precision such as datetime2(0), the step, as far as the business rule is concerned, grows to one second. Picking a neighbor with the wrong precision means mistaking a value that is actually in the same partition for the other side of the boundary.

The Money Boundary: 499.99 and 500.00
The partitions of the shipping rule are "below 500" and "500 and above", with 0.00 at the bottom. The test data comes straight from this number line:
namespace Bookstore.Shipping.Tests;
public sealed class FreeShippingPolicyTests
{
// 3-value BVA: boundary values 499.99 and 500.00, neighbors 499.98 and 500.01 (smallest step: 1 kurus)
public static TheoryData<decimal, bool> ThresholdBoundaries => new()
{
{ 0.00m, false },
{ 499.98m, false },
{ 499.99m, false },
{ 500.00m, true },
{ 500.01m, true },
};
[Theory]
[MemberData(nameof(ThresholdBoundaries))]
public void Free_shipping_starts_at_exactly_500_try(decimal discountedSubtotal, bool expected)
{
Assert.Equal(expected, FreeShippingPolicy.Qualifies(discountedSubtotal));
}
}
The reason we use TheoryData<decimal, bool> instead of InlineData is explained in the Unit Test series' "Data-Driven Tests in xUnit v3" post: an attribute argument can't be a decimal, and we don't want to risk a conversion through double for values at the kuruş level.
One more detail: the rule says "discounted subtotal". The subtotal must already be rounded to the kuruş before it gets here; if an unrounded value like 499.995 reaches the policy class, a region appears between 499.99 and 500.00 that the rule never defines. So to know where the boundary is, you need to know where rounding happens. The Solution Patterns series' "Money and Rounding" post describes keeping rounding in one place, the Money value object; in that case this policy always receives an amount with two decimals.
The Date Boundary: 23:59:59 or 00:00 the Next Day?
On the campaign's time axis there are three partitions: before it starts, during the campaign, and after it ends. The boundaries are two instants: 1 November 00:00 and 1 December 00:00 in Istanbul time. Defining the campaign as a half-open interval, "1 November 00:00 included, 1 December 00:00 excluded", makes life easier: you don't have to find the last instant of the "last day", because the end instant is the start of the next day and isn't part of the interval. The test data:
using Microsoft.Extensions.Time.Testing;
namespace Bookstore.Catalog.Tests;
public sealed class CampaignTests
{
// Campaign 1-30 November in Istanbul time; interval [1 Nov 00:00, 1 Dec 00:00)
private static readonly TimeSpan Istanbul = TimeSpan.FromHours(3);
private static readonly DateTimeOffset Start = new(2026, 11, 1, 0, 0, 0, Istanbul);
private static readonly DateTimeOffset End = new(2026, 12, 1, 0, 0, 0, Istanbul);
private static readonly TimeSpan Tick = TimeSpan.FromTicks(1);
private static readonly Campaign NovemberSale =
Campaign.ForDays("NOVEMBER25", 0.25m, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30));
// Boundary values and their neighbors (smallest step: 1 tick = 100 ns)
public static TheoryData<DateTimeOffset, bool> Boundaries => new()
{
{ Start - 2 * Tick, false },
{ Start - Tick, false },
{ Start, true },
{ Start + Tick, true },
{ End - 2 * Tick, true },
{ End - Tick, true },
{ End, false },
{ End + Tick, false },
};
[Theory]
[MemberData(nameof(Boundaries))]
public void Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(DateTimeOffset instant, bool active)
{
Assert.Equal(active, NovemberSale.IsActiveAt(instant));
}
[Fact]
public void Calendar_drops_the_campaign_at_midnight_istanbul_time()
{
FakeTimeProvider time = new(End - Tick);
CampaignCalendar calendar = new(time);
Assert.Single(calendar.ActiveCampaigns([NovemberSale]));
time.Advance(Tick);
Assert.Empty(calendar.ActiveCampaigns([NovemberSale]));
}
}
In the test we wrote Istanbul as a fixed +03:00 rather than through TimeZoneInfo. Turkey has used UTC+3 all year since 2016; keeping the test's expected value independent of the time zone database the code uses stops the two from making the same mistake. The last test takes CampaignCalendar, which lists campaigns, right to the boundary with FakeTimeProvider and advances it by one tick. We covered TimeProvider and FakeTimeProvider in the Unit Test series' "Hard Cases in Unit Testing" post; what matters here is that this test can't be written for code that doesn't take the clock from outside. There's no way to take a class reading DateTimeOffset.UtcNow to 23:59:59.9999999 on 30 November.
Against the old code, together with the shipping tests (stack traces trimmed):
failed Bookstore.Shipping.Tests.FreeShippingPolicyTests.Free_shipping_starts_at_exactly_500_try(discountedSubtotal: 500.00, expected: True) (0ms)
Assert.Equal() Failure: Values differ
Expected: True
Actual: False
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-11-01T00:00:00.0000001+03:00, active: True) (1ms)
Assert.Equal() Failure: Values differ
Expected: True
Actual: False
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-11-01T00:00:00.0000000+03:00, active: True) (0ms)
Assert.Equal() Failure: Values differ
Expected: True
Actual: False
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-12-01T00:00:00.0000000+03:00, active: False) (0ms)
Assert.Equal() Failure: Values differ
Expected: False
Actual: True
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-12-01T00:00:00.0000001+03:00, active: False) (0ms)
Assert.Equal() Failure: Values differ
Expected: False
Actual: True
failed Bookstore.Catalog.Tests.CampaignTests.Calendar_drops_the_campaign_at_midnight_istanbul_time (4ms)
Assert.Empty() Failure: Collection was not empty
Collection: [Campaign { Code = NOVEMBER25, Rate = 0.25, StartsAt = 11/1/2026 12:00:00 AM +00:00, EndsAt = 11/30/2026 11:59:59 PM +00:00 }]
Test run summary: Failed!
total: 42
failed: 6
succeeded: 36
skipped: 0
500.00 caught the shipping bug; 2-value BVA is enough for that one. For the campaign, the four values right around the start and the end are red. The last test also prints the Campaign record, so it shows the source of the bug: StartsAt and EndsAt have a +00:00 offset. The campaign has shifted by three hours.
The first fix is to convert the days to Istanbul time. Emre still leaves the end at 23:59:59:
private static readonly TimeZoneInfo Istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
public static Campaign ForDays(string code, decimal rate, DateOnly firstDay, DateOnly lastDay) =>
new(code, rate, AtIstanbul(firstDay, TimeOnly.MinValue), AtIstanbul(lastDay, new TimeOnly(23, 59, 59)));
public bool IsActiveAt(DateTimeOffset instant) => instant >= StartsAt && instant <= EndsAt;
private static DateTimeOffset AtIstanbul(DateOnly day, TimeOnly time)
{
DateTime local = day.ToDateTime(time);
return new DateTimeOffset(local, Istanbul.GetUtcOffset(local));
}
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-11-30T23:59:59.9999999+03:00, active: True) (0ms)
Assert.Equal() Failure: Values differ
Expected: True
Actual: False
failed Bookstore.Catalog.Tests.CampaignTests.Campaign_runs_from_first_day_to_end_of_last_day_in_istanbul(instant: 2026-11-30T23:59:59.9999998+03:00, active: True) (0ms)
Assert.Equal() Failure: Values differ
Expected: True
Actual: False
failed Bookstore.Catalog.Tests.CampaignTests.Calendar_drops_the_campaign_at_midnight_istanbul_time (1ms)
Assert.Single() Failure: The collection was empty
Test run summary: Failed!
total: 16
failed: 3
succeeded: 13
skipped: 0
The time zone is fixed, and now the second bug shows up. A customer adding a book to the cart at 23:59:59.5 on 30 November doesn't see the campaign price. The values that catch it are End - Tick, the largest value of the active partition, and its neighbor End - 2 * Tick. What decides it here isn't the choice between 2-value and 3-value but precision. Someone who thinks "the last instant is 23:59:59" and picks the boundary value with second precision tests 23:59:59.0 and 1 December 00:00; both give the right answer and the bug stays hidden. Because the rule's real precision is a tick, the boundary value must be picked with tick precision too.
The lasting fix is to move the end to the start of the next day and make the interval half-open:
public static Campaign ForDays(string code, decimal rate, DateOnly firstDay, DateOnly lastDay) =>
new(code, rate, StartOfDayInIstanbul(firstDay), StartOfDayInIstanbul(lastDay.AddDays(1)));
// [StartsAt, EndsAt): the end instant is not part of the campaign
public bool IsActiveAt(DateTimeOffset instant) => instant >= StartsAt && instant < EndsAt;
private static DateTimeOffset StartOfDayInIstanbul(DateOnly day)
{
DateTime local = day.ToDateTime(TimeOnly.MinValue);
return new DateTimeOffset(local, Istanbul.GetUtcOffset(local));
}
And >= instead of > in the shipping policy:
Test run summary: Passed!
total: 42
failed: 0
succeeded: 42
skipped: 0
duration: 358ms
"Europe/Istanbul" is an IANA identifier. On Windows, since .NET 6, FindSystemTimeZoneById also accepts IANA identifiers when ICU is available; on an old Windows Server without ICU you need the Windows identifier ("Turkey Standard Time"). The whole subject of time and time zones is in the Solution Patterns series' "Time and Time Zones" post.
Length and Collection Size
Everything that is ordered has boundaries; length and element count are ordered too. For ISBN validation, the partitions of the digit count are 0–12 (invalid), 13 (valid) and 14 and above (invalid). The valid partition has a single value, so 13 is both its smallest and its largest value. 2-value BVA gives 12, 13 and 14; 3-value adds 11 and 15:
namespace Bookstore.Catalog.Tests;
public sealed class Isbn13Tests
{
// Digit count: 0-12 invalid, 13 valid, 14+ invalid; 11..15 with 3-value BVA
[Theory]
[InlineData("97804411727", false)]
[InlineData("978044117271", false)]
[InlineData("9780441172719", true)]
[InlineData("97804411727190", false)]
[InlineData("978044117271900", false)]
[InlineData("978-0-441-17271-9", true)]
[InlineData("", false)]
public void Isbn_must_have_exactly_13_digits(string value, bool valid)
{
Assert.Equal(valid, Isbn13.IsValid(value));
}
}
The hyphenated row is a reminder of another boundary: 13 digits with hyphens make 17 characters. The boundary for the digit count is 13; the length boundary of the raw input is 17. If the ISBN column in the database is nvarchar(13) and some code path writes without normalizing, the boundary shows up in the column, not in the application. Look for boundaries not only in the sentence of the rule but in every layer the value passes through.
Collection size is tested the same way. A cart can hold at most 50 different book lines. The partitions are 0 lines (empty cart), 1–50, and 51 and above; the test fills the cart to one below the boundary and adds one more line:
namespace Bookstore.Ordering.Tests;
public sealed class CartTests
{
// 1-50 lines are valid: empty cart, 49 -> 50 and 50 -> 51
[Theory]
[InlineData(0, true)]
[InlineData(49, true)]
[InlineData(50, false)]
public void Cart_holds_at_most_50_lines(int existingLines, bool accepted)
{
Cart cart = new();
for (int i = 0; i < existingLines; i++)
{
cart.TryAdd(new OrderLine($"isbn-{i}", 100m, 1));
}
bool added = cart.TryAdd(new OrderLine("9780441172719", 250m, 1));
Assert.Equal(accepted, added);
Assert.Equal(accepted ? existingLines + 1 : existingLines, cart.Lines.Count);
}
}
The second assertion matters: when the 51st line is rejected, it also verifies that the cart didn't change. Saying "it rejected" at the boundary isn't enough; you also need to see that the state wasn't corrupted after the rejection.
Production Notes
Define the boundary in one place. A constant like FreeShippingPolicy.Threshold must be the same value in the "12.50 TRY left for free shipping" message on the campaign screen, in the e-mail text and in the report filters. When the same boundary is written in three places with three comparisons, one ends up > and another >=, and the customer sees the screen and the invoice saying different things.
Store time in UTC, talk about the rule in local days. The back office enters the campaign as "1–30 November"; which time zone those days are in is a business decision, and it should get its meaning in a single place in the code, a factory method like ForDays. If the campaign also takes orders from abroad, ask Selin "1 December 00:00 in whose time?"; the answer decides the +03:00 in the test.
Check the database's precision. If you compute an instant with tick precision and write it to a column with second precision, the end instant read back from the record may differ from the computed one. SQL Server's datetime2 defaults to 7 digits of precision, that is, a tick; the datetime type rounds to roughly 3 milliseconds. Running the boundary test once more with a value read from the database catches this difference at the integration test level.
Watch the real boundary events in production. The number of orders created at the campaign price in the first minutes after the campaign ends is the most direct measure of a boundary bug. A night when that number isn't zero tells you your tests skipped a neighbor.
Common Pitfalls
Testing the boundary only on the valid side. Testing 500.00 and skipping 499.99 shows only that 500.00 is free, not that the boundary is in the right place. The test passes even if the code says >= 499.50m. Both sides of a boundary are tested together.
Picking the neighbor with the wrong precision. Thinking in seconds and treating 23:59:59 as "the last instant" is the source of the second bug in this post. 0.01 for amounts, the precision of the type and the column for dates, one character for lengths, one element for collections.
Testing a date boundary with the real clock. Code that depends on DateTimeOffset.UtcNow can only be tested at the boundary if the test runs at exactly that second. Taking the clock from outside with TimeProvider is what makes the boundary test possible.
Computing the time zone in the test the same way as in the code. A test that produces its expected value with TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul") shares the code's assumption. Write the expected instant explicitly, as a fixed offset or in UTC.
Trying to apply BVA to an unordered input. A payment method has no "boundary". Use BVA only on ordered partitions; equivalence partitioning is enough for enums and categories.
A Small Experiment
★☆☆ Break FreeShippingPolicy.Qualifies as discountedSubtotal >= 499.99m. Which of the five rows in this post turns red? Would it be caught with the 2-value BVA values alone?
★★☆ Add the rule "the last day ends at 18:00" to campaigns: the November campaign ends on 30 November at 18:00, Istanbul time. What parameter would you add to ForDays? Write the boundary values and their neighbors first, then change the code.
★★★ Derive test cases with 3-value BVA for the institutional 20-item minimum (18, 19, 20, 21) and run them against InstitutionalPricing.Price. Then write a broken version whose minimum check is totalQuantity == MinimumQuantity - 1, so it rejects only 19 items. Do the 2-value BVA values (19 and 20) catch this bug? Which value added by 3-value does?
Equivalence partitioning and boundary value analysis looked at inputs one at a time. In the next post we move to where a rule depends on a combination of several conditions: decision table testing. We'll build and collapse the table from the combinations of membership, cart total, region and coupon for the shipping fee, see how the table exposes a contradiction in the requirement, and why copying the test from the rule table in the implementation doesn't work.