Test iOS Collection Identity: Hashable and Field Updates

Test iOS Collection Identity: Hashable and Field Updates

Test iOS Collection Identity: Hashable and Field Updates

You fetch the same record from the server again: its name has changed, but an extra row appears in the list. Elsewhere, you use it as a Dictionary key and can no longer retrieve its previous state. Before rewriting your refresh logic, take the model to a cloud Mac and test equality and collection behavior in isolation. This often reveals a mismatch in how identity is defined. The tests below cover value models only: no simulator or live API is required.

Define what makes two records the same

Suppose an app manages members across multiple tenants. A member ID is unique only within its tenant, and the member’s name can change. Business identity is therefore “tenant ID + member ID”; the name is not part of it. Document that rule in the test description before implementing the protocols.

There are two separate questions here: do two values represent the same member, and are all their fields identical? If you rely on synthesized Hashable conformance for a struct, all stored properties normally participate in equality. Once the name changes, the two snapshots are no longer equal.

Do not casually change equality semantics across all your models just to remove duplicates. If you need to compare complete snapshots, a safer design is to define a separate immutable identity key and use a dictionary that maps those keys to full records. This article uses a model whose equality is based on identity to make the contract explicit.

Equal values must follow the same hashing rules, but values with the same hash are not necessarily equal. Hashing narrows the search space; it does not provide a permanent record ID.

Lock down equality and hashing with a minimal script

Check that the Xcode command-line tools are available on the cloud Mac, run xcrun swift --version, and record the output alongside the code’s commit identifier. Create identity-contract.swift with the following contents. The example has no external dependencies and does not read credentials.

struct Member: Hashable {
    let tenantID: String
    let memberID: String
    var displayName: String

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.tenantID == rhs.tenantID &&
        lhs.memberID == rhs.memberID
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(tenantID)
        hasher.combine(memberID)
    }
}

let original = Member(
    tenantID: "team-a",
    memberID: "member-7",
    displayName: "Old"
)
var renamed = original
renamed.displayName = "New"

let otherTenant = Member(
    tenantID: "team-b",
    memberID: "member-7",
    displayName: "Old"
)

precondition(original == renamed, "Rename changed identity")
precondition(original.hashValue == renamed.hashValue,
             "Equal values hash differently")
precondition(original != otherTenant, "Tenant boundary lost")

var members: Set<Member> = [original]
precondition(!members.insert(renamed).inserted,
             "Duplicate identity inserted")
precondition(members.count == 1, "Unexpected member count")
precondition(members.first?.displayName == "Old",
             "Insert unexpectedly replaced the member")

let previous = members.update(with: renamed)
precondition(previous?.displayName == "Old",
             "Previous member missing")
precondition(members.count == 1, "Update changed member count")
precondition(members.first?.displayName == "New",
             "Updated fields missing")

members.insert(otherTenant)
precondition(members.count == 2, "Distinct tenants merged")

let states: [Member: String] = [original: "selected"]
precondition(states[renamed] == "selected",
             "Lookup failed after rename")
precondition(states[otherTenant] == nil,
             "Lookup crossed tenant boundary")

Equality and hash(into:) use the same identity fields. Those fields are declared with let, while display fields can be updated. Watch especially for a subtle mistake: ignoring the name in equality but including it in the hash. This violates the protocol’s requirements, and you cannot expect every run to expose it immediately.

Verify both deduplication and updated content

Run the following from the directory containing the script:

set -eu
xcrun swiftc -Onone identity-contract.swift -o identity-contract
./identity-contract

The script exits silently on success; a failed precondition produces a nonzero exit status. Keep -Onone and do not use -Ounchecked, which disables safety checks. When integrating this into an existing pipeline, use the command’s exit status directly to determine whether the test passes.

Asserting that the Set contains one member is not enough. When insert encounters an equal member, it does not replace the existing content, so successful deduplication does not mean the name was updated. The example first checks that the old name remains, then uses update(with:) to verify that the new name has been stored.

The Dictionary test answers a different question: can a key whose name has changed still retrieve the original state? If you need to retain the latest full record, let the dictionary value hold that record and keep only identity in the key, rather than putting display data into it.

Cover edge cases, not specific hash numbers

Separate identity rules from input rules

Whether string identifiers are case-sensitive and whether empty values are allowed should be defined by the API contract. Do not silently transform them inside the hash function. If normalization is required, perform it before creating the model, and make sure equality and hashing both use the same normalized result.

Add these checks to your project’s tests:

Do not assert that a particular hashValue must equal a fixed integer, or require hashes for different identities never to collide. Per-process randomization and valid hash collisions make such tests meaningless. Rerunning the script can help uncover unstable behavior, but it cannot replace a review of the field rules.

Apply the contract to production models and merge checks

The standalone script proves the example’s logic. When integrating it into the project, move the assertions into a test target and import the production model directly. Do not maintain a test-only copy indefinitely: otherwise, a bug in the production code may leave the script passing.

If your current model is a reference type, it is especially important not to modify fields used in hashing while the object is a collection member. The collection will not automatically rebuild its index when the object’s internals change. Prefer an immutable identity key. If identity really must change, remove the old member first, then create and insert a new one.

Finally, review persistence and page selection state: both should store explicit business identifiers, not hash numbers or collection iteration positions. The acceptance criteria are concrete: renaming creates no duplicate records, updated content can be read back, members from different tenants never merge, and lookup and removal still work correctly.

Frequently asked questions

Does a matching hashValue mean two different records are equal?

No. Hash collisions are valid, and collections use equality to distinguish values. Equal values must hash consistently, but different identities do not need different hash values.

Will a Set automatically replace a record after its name changes?

No. Changing a copy outside the Set does not change the stored member. Use update(with:) to replace an equal member, then check that the count stays unchanged and the new fields are stored.

Can hashValue serve as a persistent database key?

No. Swift hashing is randomized and is not stable across processes or versions. Persist a server-issued identifier or UUID, and include the tenant identifier when IDs are tenant-scoped.

A dedicated Mac for your projects

Mac mini physical nodes, not virtual machines. Rent by the day, week, month, or quarter. Choose your use case and specs, then check real-time availability in the console.

Choose a model and rental term View full pricing