Let’s start by comparing two example tests:

it "counts the number of active email addresses" do
  account = FactoryBot.create(:account)
  expect(account.active_email_count).to eq account.emails.active.count
end
it "counts the number of active email addresses" do
  account = FactoryBot.create(:account)
  account.emails.build(addr: "example1@example.com", active: true)
  account.emails.build(addr: "example2@example.com", active: true)
  account.emails.build(addr: "inactive1@example.com", active: false)

  expect(account.active_email_count).to eq 2
end

At first glance, these two tests might be testing the same thing. Now, what if I told you that the factory doesn’t create any emails? The first test would still pass! The first test would still pass even if you hard-coded the return value of active_email_count to 0! That’s not what we intended!

And to make this even more insidious, the way the assertion line reads in example 1 is a tautology. It would surprise anyone if that ever failed, no?

You might think to yourself, “but no one would ever do that” and you’re probably right … for this example. But it seems very possible in a large enough system, that two developers could independently change those and effectively wind up in this situation.

This testing pattern (literal value) is a culmination of several others, namely:

  • Behavior not implementation

    If your test cares about the end result, not the path to the result, you’re free to refactor the implementation to your heart’s content. As long as you get the right answer at the end, your test still states a fact.

    You’re also not duplicating the implementation in the test, creating orthogonality between the two systems (implementation and test).

  • Readability

    If you use a literal value, you don’t have to understand two pieces of code to read the test. You just have to understand the SUT (subject under test) to reason about the end result.

  • Determinism

    Probably the most insidious, and hard to convince you of, but it’s present in the example above. When your assertion/expectation is a literal value, changes to other systems won’t impact your expectation. That is, by changing your production code, you can’t break both sides of the test - actual & expectation - only your actual.

Here’s another example that doesn’t rely on a database:

describe("Person#age", () => {
  it("is calculated from the birthdate", () => {
    const person = new Person({ birthdate: "2019-03-04" });

    const expected = Temporal.Now.plainDateISO().until(Temporal.PlainDate.from("2019-03-04")).days / 365; 
    expect(person.age()).toEqual(expected);
  })
})
describe("Person#age", () => {
  it("is calculated from the birthdate", () => {
    const person = new Person({ birthdate: "2019-03-04" });
    const now = Temporal.PlainDate.from("2025-04-19");

    expect(person.age({ now })).toEqual(6);
  })
})

By controlling the inputs & outputs, we can arrive at a known literal expected value. This reduces flakiness related to time’s non-determinism and makes it easier to reason about test behavior.

Times you may want to ignore this rule

  1. Database IDs

    This is particularly prevalent in the Rails community and testing patterns. The idea is this: I want to assert that a particular record was inserted/found in a dataset and the primary key of that record is the shortest-path to that assertion.

    In cases like this, changing the architecture (or working around it) to assert on the literal ID would be more effort than it’s worth. This is probably fine:

    note = Note.create!(text: "original")
    put :notes, params: { text: "changed" }, as: :json
    expect(parsed_response[:id]).to eq note.id
    
  2. Fixture assertions

    Got a huge bundle of JSON/XML/Markdown/content you’re asserting against and you don’t want to write that all out in your test?

    For skimmability (related, but different from readability), it may be worth it to move that to a separate file and assert on the content there. Note that this can make your tests hard to work with, but the lowered “noise” in your test could be worth it. If you go with this kind of pattern, it may also be worth spinning out several other tests that assert on lower-level details of the content for more targeted error messages.

    expect(page.html()).toEqual(`
      <!DOCTYPE html>
      <html>
        <!-- Imagine dozens of lines of HTML here -->
      </html>
    `)
    
    expect(page.html()).toEqual(fixtureFile("page-with-user-content.html").readSync())
    
  3. “Change” expectations

    In some tests, it can be easier to reason about the assertions as a diff between two values. For instance, imagine an HTTP POST that creates several records in the database. Asserting the change in the count(s) may be better assertion for the test-layer than checking each individual value.

    expect do
      post :users, params: { username: "Martin", password: "Password1!" }, as: :json
    end.to change { User.count }.by (1)
    

Conclusion

Favor writing test assertions with literal values. This helps to improve readability and correctness, while decreasing flakiness in your test suite.