Testing & Reliable Design · Lesson 6

Test identity and value semantics separately

Select the equality assertion that matches the API promise.

Define the observable contract

An equality assertion must match what the API promises. A domain identifier may compare by its declared value even when two independent objects represent that value. A cache may instead promise to return the same instance, making identity important. Confusing those contracts produces misleading failures or lets real ownership errors pass. This lesson uses a record whose generated equality compares its Code, then contrasts that behavior with ReferenceEquals. It does not assume every class uses record equality or that two similarly printed objects must be interchangeable in every application.

Walk through the real program

The first and equal variables each refer to a separately constructed CustomerId containing A1. Their record comparison prints value:True. ReferenceEquals for those two instances prints separate:False. Alias is assigned the reference in first without constructing another object, so the last line prints alias:True. These three observations are consistent because they answer different questions. The program uses ordinary managed values and produces no address or allocation measurement. It is also not an assertion about whether a serializer, repository, or UI layer should preserve instance identity unless that layer explicitly promises to do so.

Why the design is testable

Choose the observation from the behavior being specified. A deserialized immutable identifier normally needs matching contents, not the original object address. A shared-session cache might require repeated lookups to return one identity so consumers observe the same mutable state. A snapshot API might require independent storage even when initial contents are equal. Record equality is convenient for this simple model, but a nested mutable collection can complicate both equality and independence. Keep content equality, aliasing, deep immutability, and construction ownership as distinct design questions rather than treating one comparison as a universal correctness test.

A boundary that matters

Using ReferenceEquals to test a value round trip can reject perfectly correct data reconstruction. Using only value equality to test an independent copy can miss shared mutable state. Hash codes are also not durable unique IDs or a substitute for either comparison; unequal values may collide, and implementation details can vary. Never print runtime hash values as an expected cross-target identity proof. For a class with custom equality, test that equality and hashing satisfy their collection contracts without assuming that the == operator automatically has the same implementation as Equals.

Practice: isolate one behavior

First change the separately constructed identifier to B2. Predict that value equality becomes false while the two ReferenceEquals observations remain false and true respectively. Hint: the alias still points to first, and changing the construction of equal does not change that relationship. Then construct another A1 value and compare it with both original instances. Explain the results without appealing to variable names or a memory-address screenshot. The declared record contract, not how similar the names look, determines the value comparison.

Practice: challenge another assumption

For a second exercise, design a snapshot of an object containing a mutable list. State separately that the snapshot initially has equal contents and that later mutation of one owner's list must not alter the other. Hint: compare contents first, then perform a controlled mutation to test independence. Do not assume a shallow record with copy provides that second guarantee. Add a cache scenario where shared identity is intentional and explain why the appropriate test there is different from the snapshot's independence check.

Review the evidence

A good test name should reveal whether it checks value, identity, or mutation isolation. The unchanged three lines establish all the distinctions needed for this small example without pretending to describe every class type. When a test fails, identify which contract it actually used before changing the implementation. This discipline matters in serialization, caching, entity tracking, and copy operations: the wrong equality assertion can make correct behavior look broken or turn a shared-state regression into a superficially passing value comparison.

  • Value equality and reference identity answer different contractual questions.
  • The baseline prints True, False, and True under their respective labels.
  • Mutation independence needs a behavioral check beyond initial equal contents.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

var first = new CustomerId("A1");
var equal = new CustomerId("A1");
var alias = first;
Console.WriteLine($"value:{first == equal}");
Console.WriteLine($"separate:{ReferenceEquals(first, equal)}");
Console.WriteLine($"alias:{ReferenceEquals(first, alias)}");
record CustomerId(string Code);

Expected output

value:True
separate:False
alias:True
Try this
Equal domain values need not be the same object instance.

Further reading