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"docontext"when the feature is disabled"dobeforedoSetting.current.update!(features: {feature.to_s=>false})Setting.reset_cache!endit"responds with 404"dogetpathexpect(response).tohave_http_status(:not_found)endendend
The host group supplies two let bindings and one line:
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.
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:
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 memoFeature.enabled?(:blog)# => true, and the row says falseSetting.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.
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.
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
The host group supplies two
letbindings and one line: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 :referralsfrom 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_actiontobefore_actioninside 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 :apibelowbefore_action :authenticate_api_user!in the API base controller, on top of that change, fails the sixth. Restoringprependwith 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
BlogControllerdeclaresallow_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::BaseControlleris the more uncomfortable one, because it looks like it is testing the right thing. Itsgated_by :apiis written on the line above itsbefore_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 withprepend.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
prependback and it goes green with the lines still swapped, which is the proof thatprependis 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.currentmemoises the settings row on anActiveSupport::CurrentAttributessubclass: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
CurrentAttributessubclass around each request, in a spec as much as in production, so the controller called byget pathre-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.firstbuilds 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 throughSetting.current: all of them write around the memo. The rule that falls out is short enough to remember. Write throughSetting.current, or callreset_cache!, and never assume which one you did.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
Currentbetween 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.