Should I test that?

Should I test model associations?

Verdict

No

Do not write a test for a plain belongs_to, has_many or ForeignKey declaration; test the association options that decide what a delete removes or which rows come back.

Why

Do not test plain association declarations; test the options that carry a rule. The typical case is a Rails, Django or Laravel model with a few belongs_to, has_many or ForeignKey lines and default options. Blast radius is users, because a broken association breaks a page, and Change frequency is rarely, because associations follow the schema. Detectability is immediately, because a missing or misnamed association raises an error on the first call and fails every test of the code that reads it. Reversibility is trivial, because a call that raises writes nothing, and Test cost is moderate, because a test saves two records in a test database, so rule R8 gives Do not test.

When the decision changes
WhenDecisionWhy
The association sets dependent: :destroy, a JPA cascade or Django on_delete=CASCADETest: delete a parent that has children and check which rows remainDetectability moves to eventually and Reversibility to costly, because nobody checks what a delete removed and deleted rows come back only from a backup
The association has a scope, such as has_many :active_subscriptions filtered by statusTest: one record that matches the scope and one that must notChange frequency rises to regularly, Detectability to eventually and Reversibility to with-effort, because a wrong scope returns plausible rows that code acts on
Code uses the association as an access check, such as current_account.projects.find(id)Test mandatory, including a request for a project of another accountBlast radius rises to safety-or-legal, Detectability to never and Reversibility to costly, because a broken check shows another customer's data without an error
Your code adds children through the mappedBy side of a JPA @OneToManyTest minimally: save a child through the mappedBy side and read it back through the owning sideDetectability moves to eventually and Reversibility to with-effort, because Hibernate writes the foreign key only from the owning side and a child without its parent link raises no error
A shoulda-matchers one-liner such as it { should have_many(:tasks) } takes a minute to writeDo not add a shoulda-matchers one-liner for a plain declarationTest cost falls to trivial, but Detectability immediately and Reversibility trivial still set the decision

What breaks if you don't test

A plain declaration fails loudly. Delete has_many :orders from User, and the order history page raises NoMethodError in the first request test that renders it. The silent failures sit in the options: a dependent: :destroy on the wrong has_many deletes customer records, and a missing one leaves orphan rows that pages list with broken links. Neither raises an error, and customers notice weeks later.

What you lose if you over-test

The common over-test is a one-liner per declaration: it { should belong_to(:user) } next to belongs_to :user. Each spec repeats a model line, so every new association costs two edits, and the spec passes whatever the association does on delete. Testing both directions doubles the one-liners per relation, one on each of its two models, and each one-liner still checks only its own line.

What to do instead

Let the tests of the code that reads each association cover the declaration: a request test of the order history page fails when user.orders breaks. In CI, Django's manage.py check rejects a ForeignKey to a missing model, and Hibernate fails at startup when mappedBy names a missing field. Add a foreign key constraint for every belongs_to, so the database rejects a child without a parent. When an association carries an option from the conditions table, write one database test of that option. The Rails association guide lists each dependent: value, and the Django reference describes each on_delete choice.

When the answer changes

  • The association sets dependent:, a cascade or on_delete, which decides what a delete removes.
  • A scope on the association filters which rows come back.
  • Code uses the association to decide which records a user may see.

Real incident + Code example

The venue delete that took the bookings with it

On a Rails booking app I worked on, Venue declared has_many :bookings, and the spec it { should have_many(:bookings) } confirmed the line. A developer added dependent: :destroy so that a seed script could delete venues without a foreign key error, and the spec stayed green. Six weeks later an account manager deleted a closed venue, and its 312 past bookings went with it. We restored the rows from the nightly backup by hand and changed the option to restrict_with_error, which the Rails API describes as adding an error to the owner when children exist. The one-liner became a test of what a delete does:

RSpec.describe Venue do
  it "refuses to delete a venue that has bookings" do
    venue = create(:venue)
    create(:booking, venue: venue)

    expect(venue.destroy).to be(false)
    expect(Booking.where(venue_id: venue.id).count).to eq(1)
  end

  it "deletes a venue without bookings" do
    venue = create(:venue)

    expect { venue.destroy }.to change(Venue, :count).by(-1)
  end
end

FAQ

Is it necessary to test relationships in both directions?

No, a plain relationship needs no test in either direction, because the code that reads each side fails its own tests when a declaration breaks. The exception is a JPA mapping that your code writes through the mappedBy side: write one test that saves through that side and reads back through the other.

Should I use shoulda-matchers to test associations?

No, a bare one-liner such as it { should belong_to(:user) } repeats the model line, and a deleted association raises an error without it. A matcher with a qualifier, such as have_many(:bookings).dependent(:restrict_with_error), is a cheap check of an option.

Should I test dependent: :destroy?

Yes, test every dependent: option with one database test that deletes a parent with children and counts the rows that remain. A wrong value deletes customer records or leaves orphan rows without an error.