Skip to main content
ANVISoftware Solutions
Lesson 4 of 13Intermediate16 min

Assertions

By the end of this lesson

Assert precisely, and write failure messages that explain themselves.

An assertion is the sentence in a test that decides pass or fail. It is also, when the test fails, the only explanation anyone gets.

That second role is the one people underuse. A vague assertion still catches a total failure, but it wastes the moment when it could have told you exactly what went wrong. Aim for this standard: the test name plus the failure output should let you diagnose the problem without opening the code under test.

Assert on the value, not on the absence of disaster

Three assertions on the same call, in increasing order of usefulness
C#
// Weak: passes for any non-null order, including a completely wrong one
Order order = service.CreateOrder("E-1042", quantity: 3, unitPrice: 25m);
Assert.NotNull(order);

// Better: checks one real value, but says nothing about the rest
Assert.Equal(75m, order.Total);

// Precise: states the whole outcome this test cares about
Assert.Equal("E-1042", order.EmployeeId);
Assert.Equal(3, order.Quantity);
Assert.Equal(75m, order.Total);
Assert.Equal(OrderStatus.Draft, order.Status);
  • Assert.NotNull fails only when the method returns nothing at all. An order with a total of zero, the wrong employee and the wrong status passes it.
  • The middle version is a real check but leaves three quarters of the result unverified.
  • The last version defines the expected outcome. When any line fails, the message names the field and both values.
  • Not every test needs four assertions. Assert on what the test is about — but "not null" is almost never what a test is about.

The assertions that cover most situations, and when each fits:

Assert.Equal(expected, actual)
Value comparison. The workhorse. Expected goes first — reversing the arguments reverses the failure message and sends readers looking in the wrong place.
Assert.Equal(expected, actual, precision)
For decimal and double comparisons where rounding is a factor. Prefer decimal with an exact expected value for money, and keep precision for genuine floating-point work.
Assert.Contains / Assert.DoesNotContain
For collections and strings when the whole content is not the point. On a collection the failure message lists what was actually there, which is usually enough to diagnose.
Assert.Empty / Assert.Single
Clearer than comparing a count. Assert.Single also returns the item, so you can assert on it immediately afterwards.
Assert.Throws<T>
For behaviour that is meant to reject its input. Covered in detail below, because it is the one people skip.

Asserting that something is rejected

Refusing bad input is behaviour, and it is behaviour that tends to be added under pressure and quietly removed during a refactor. It needs tests as much as the happy path does.

Assert.Throws runs a piece of code, requires that it throws the exception type you named, and hands the exception back so you can check it further.

Testing the guard clause in PriceCalculator
C#
[Fact]
public void ApplyDiscount_WithPercentageAboveOneHundred_ThrowsArgumentOutOfRange()
{
    var calculator = new PriceCalculator();

    var exception = Assert.Throws<ArgumentOutOfRangeException>(
        () => calculator.ApplyDiscount(100m, 120));

    Assert.Equal("percentage", exception.ParamName);
}

[Fact]
public void ApplyDiscount_WithNegativePercentage_ThrowsArgumentOutOfRange()
{
    var calculator = new PriceCalculator();

    Assert.Throws<ArgumentOutOfRangeException>(
        () => calculator.ApplyDiscount(100m, -5));
}
  • The action is wrapped in a lambda — () => ... — because the assertion has to call it itself in order to catch what comes out.
  • Assert.Throws<T> requires that exact type. A subclass does not satisfy it. Use Assert.ThrowsAny<T> when a derived type is acceptable.
  • Capturing the returned exception lets you assert on its detail. Checking ParamName confirms the guard rejected the right argument, which catches a copy-paste mistake between two guards.
  • For an async method use await Assert.ThrowsAsync<T>(...) and make the test method async Task. Forgetting the await makes the test pass regardless.

The name is part of the assertion

A precise assertion and a vague name still leave you guessing. Compare what two failure reports tell you about the same broken code:

 Vague name, weak assertionSpecific name, precise assertion
Test nameTestDiscountApplyDiscount_WithTwentyPercentOffFortyNine_RoundsToTwoDecimals
AssertionAssert.True(result > 0)Assert.Equal(39.99m, result)
Failure outputExpected True, actual FalseExpected 39.99, actual 39.992
What you knowSomething about discounts is wrongRounding is not being applied
Next stepOpen the code and start readingGo to the rounding line

Summary

  • An assertion decides the result and also writes the failure report — treat both as its job
  • Assert on specific values; "not null" passes for almost every wrong answer
  • Never compute the expected value with the formula under test, or the test confirms the bug
  • Use Assert.Throws for rejection behaviour, and check the exception type and ParamName rather than the message text
  • A specific test name plus a precise assertion means you can diagnose from the test output alone

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Strengthen weak assertions

You inherit these three tests. Rewrite each assertion so that a failure explains itself:

1. Assert.NotNull(service.FindOrders("E-1042")) — the employee has exactly two orders.

2. Assert.True(order.Total > 0) — the total should be 67.50.

3. A test that calls service.Submit on an order that is already submitted, and asserts nothing.

Show solution

For the first, assert on the count and on the content: Assert.Equal(2, orders.Count) plus a check that both belong to E-1042. FindOrders returning an empty list currently passes, which is the bug the test should catch.

For the second, Assert.Equal(67.50m, order.Total). Greater than zero is satisfied by every wrong answer except zero.

The third is the interesting one. Calling Submit twice has some intended behaviour — either it throws, or it is deliberately harmless. The test has to state which. Assert.Throws<InvalidOperationException> if resubmission is an error; assert the status and timestamp are unchanged if it is meant to be idempotent. Writing the assertion forces the question, which is why a test with no assertion is worse than no test: it hides an undecided design.

C#
[Fact]
public void Submit_WhenOrderIsAlreadySubmitted_Throws()
{
    var repository = new InMemoryOrderRepository();
    var service = new OrderService(repository);
    Order order = service.CreateOrder("E-1042", quantity: 3, unitPrice: 25m);
    service.Submit(order.Id);

    var exception = Assert.Throws<InvalidOperationException>(
        () => service.Submit(order.Id));

    Assert.Contains("already submitted", exception.Message);
}

Think about it

The self-fulfilling assertion

A test asserts Assert.Equal(price - (price * percentage / 100m), calculator.ApplyDiscount(price, percentage)).

It passes. What class of bug can it never catch, and why?

Show solution

It cannot catch a wrong formula, because it uses the same formula. If the production code multiplies instead of dividing and the test does too, both are wrong and the test is green.

It also cannot catch a missing rounding step, since neither side rounds. The test is a restatement of the implementation rather than a statement of expected behaviour.

Write expected values as literals you worked out independently — 39.99, not an expression. The independence is the whole point of the assertion.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is Assert.Equal(80m, result) preferable to Assert.True(result == 80m)?
A test asserts on the full text of an exception message. What is the main risk?
Which of these tests would pass even if the method under test were completely broken?

Saved in this browser only.