LaunchKit

Testing feature flags in Rails

September 18, 2026

A feature flag has two states and only one of them gets exercised by accident. Every other spec in the file drives the feature while it is on, so the open path is covered whether you meant it or not. The closed path is covered by exactly the specs you write for it, and while it works there is nothing to look at.

One contract, six controllers

RSpec.shared_examples "a feature-flagged route" do
  context "when the feature is disabled" do
    before do
      Setting.current.update!(features: { feature.to_s => false })
      Setting.reset_cache!
    end

    it "responds with 404" do
      get path
      expect(response).to have_http_status(:not_found)
    end
  end
end

The host group supplies two let bindings and one line:

describe "Referrals", type: :request do
  let(:path) { referral_path }
  let(:feature) { :referrals }

  it_behaves_like "a feature-flagged route"
end

Six request specs use it: referrals, the blog, the customer support form, two AI screens and the JSON API. The value of the shared example is not the four lines it saves each time. It is that the contract has one definition, so a change to what a disabled feature answers is a change to one file rather than a search for six copies that have drifted.

Asking what it actually pins

A spec that passes tells you nothing about which line of production code it is watching. The way to find out is to break the production code deliberately and see whether the spec notices, which takes about a minute per mutation and is the only method that gives a real answer.

Four mutations against the gate this suite is supposed to be protecting:

Deleting gated_by :referrals from the controller fails the referrals spec immediately. The suite does watch the gate, which is the baseline worth confirming before trusting anything else it says.

Changing prepend_before_action to before_action inside the concern fails four of the six, and that number is more interesting than a clean pass or a clean fail would have been.

Moving gated_by :api below before_action :authenticate_api_user! in the API base controller, on top of that change, fails the sixth. Restoring prepend with the line still in its new position makes it pass again.

Deleting Setting.reset_cache! from the shared example itself fails nothing. All 37 examples across the six files stay green.

Why two specs survive losing prepend

BlogController declares allow_unauthenticated_access, so it has no authentication callback in its chain at all. Appending the gate or prepending it produces the same order when there is nothing in front of it, and the blog spec cannot see a difference that does not exist for the blog.

Api::V1::BaseController is the more uncomfortable one, because it looks like it is testing the right thing. Its gated_by :api is written on the line above its before_action :authenticate_api_user!, so appending puts the gate first anyway, by declaration order. The spec passes, the comment above the gate claiming it runs before authentication is true, and neither of those facts has anything to do with prepend.

Move the two lines past each other and the spec goes red, which is the proof that the ordering is what the spec is sensitive to. Put prepend back and it goes green with the lines still swapped, which is the proof that prepend is what makes the ordering independent of how the file is typed. Four of six is not a coverage gap to fix, then. It is the correct number, and knowing which two and why is the difference between a suite you trust and a suite that is merely green.

The line in the test that tests nothing

Setting.current.update!(features: { feature.to_s => false })
Setting.reset_cache!   # delete this and nothing fails

Setting.current memoises the settings row on an ActiveSupport::CurrentAttributes subclass:

def self.current
  Current.setting ||= first_or_create!
end

So Setting.current.update!(...) fetches the memoised object and mutates it in place. The memo is not stale afterwards, it is holding the very object that was just updated. There is nothing to clear.

The request boundary would have handled it regardless. The Rails executor resets every CurrentAttributes subclass around each request, in a spec as much as in production, so the controller called by get path re-reads the row rather than inheriting whatever the example set up. Both mechanisms point the same way, and the reset is the third one.

None of that makes the line wrong to have written. It makes it a line that cannot be relied on to be doing anything, which is worth knowing before somebody copies it into a test where it looks like the load bearing part.

When the reset is the only thing that saves you

Setting.current                                       # memoise: features is {}
Setting.first.update!(features: { "blog" => false })  # write around the memo
Feature.enabled?(:blog)                               # => true, and the row says false
Setting.reset_cache!
Feature.enabled?(:blog)                               # => false

Setting.first builds a second object for the same row. Updating it writes to the database and leaves the memoised instance exactly as it was, so every flag check for the rest of that request answers from a snapshot taken before the write.

This is the shape to watch for, and it is not rare. A factory that creates settings, a service object that reaches for Setting.first, a seed script, a spec that sets up through the model rather than through Setting.current: all of them write around the memo. The rule that falls out is short enough to remember. Write through Setting.current, or call reset_cache!, and never assume which one you did.

Two lanes, both writing features bracket blog bracket equals false to the same settings row. The top lane, labelled through the memo, runs from a green box reading Setting.current.update exclamation mark, subtitled mutates the object held, to a white box in which Feature.enabled? open paren colon blog close paren answers false, noted as: agrees with the row. The bottom lane, labelled around the memo, runs from a yellow box reading Setting.first.update exclamation mark, subtitled a second object, same row, to a white box in which the same call answers true, noted as: the row says false, stale until reset underscore cache exclamation mark. Caption: both wrote the same value to the same row, only one went through the object the memo was holding, and the executor clears it at the request boundary anyway.

What this page does not cover

Testing a flag in a system spec, where the browser holds a session across several requests and the executor resets Current between each of them. Flipping a flag halfway through such a test is a different problem, and the honest answer is usually to split it into two tests rather than to reach for the cache.

Nor does it cover asserting that a disabled feature's navigation link disappears. That belongs to a view or system spec rather than a request spec asserting a status code, and the six specs here say nothing about it. A feature can be correctly gated at the controller and still advertised in the sidebar, and this suite would stay green through all of it.

More on Feature flags in Rails

← All Feature flags in Rails articles