跳到正文
cccskills
免费在 GitHub 公开

rspec-testing

Bike Index's RSpec testing conventions — how to structure specs with `context` and `let`, what kinds of tests to write, and what to avoid (mocks, controller specs, testing private methods). Trigger when writing or modifying any `*_spec.rb` file, adding test coverage for new code, refactoring tests, or designing the test layout for a new feature. Includes Good/Bad examples of the project's preferred style.

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index星标 308(GitHub 308 + cccskills 0)

实测评分 未实测

查看安装方法

包含的文件(1)

  • SKILL.md17.1 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

RSpec testing in Bike Index

This project uses RSpec. All business logic should be tested.

Run only the specs your change touches

AGENTS.md has the rule. When something fails outside the files you changed, re-run that spec file on its own first: failing alone means it's real and gets fixed, never excused as pre-existing; passing alone is the fixing-flaky-failures skill, whose first rule is that you can't reach for a retry, a looser matcher, or a deleted assertion to make it green.

What a spec prints doesn't reach you

rtk's rspec wrapper reports a summary in place of the run's output, which costs you two different things. A puts added to a scratch spec to inspect a value vanishes and the run reads as an ordinary pass — redirect it (bundle exec rspec spec/foo_spec.rb > tmp/probe.log 2>&1) or write to a file from inside the example, as fixing-flaky-failures does for browser-side values. A failure keeps its ExpectationNotMetError: line but loses the expected/got values under it, so re-running the one example tells you no more than the first run did; rtk proxy bundle exec rspec <file>:<line> is what shows them. --format documentation rescues neither.

What to test (and what not to)

  • Tests should either: help make the code correct now, or prevent bugs in the future. Don't add tests that don't do one of those things.
  • Use request specs, not controller specs — request specs go through the full middleware/routing stack, so they catch breakage controller specs can't see. Everything making the same request should be in a single test. For markup a component owns, see A component's own markup.
  • Avoid testing private methods.
  • Avoid mocking objects.
    • If making external requests, use VCR. Never write or edit a cassette by hand — record them by running the tests (see VCR cassettes).
  • Don't use tap to bundle factory creation with follow-up setup. Create the record in let/let!, then do the follow-up work on its own line (a separate statement, or a before block). One thing per line reads better and keeps the factory call clean.

Good

let!(:bike_transferred) { FactoryBot.create(:bike, :with_ownership_claimed, user:) }
before { BikeServices::OwnershipTransferer.find_or_create(bike_transferred, updator: user, new_owner_email: "new@example.com") }

Bad

let!(:bike_transferred) do
  FactoryBot.create(:bike, :with_ownership_claimed, user:).tap do |bike|
    BikeServices::OwnershipTransferer.find_or_create(bike, updator: user, new_owner_email: "new@example.com")
  end
end

Session state in a request spec comes from a real request

A request spec reads session and assigns freely, but a write doesn't outlive the call — the next request rebuilds the session from the cookie. There is no controller spec to fall back on; spec/controllers/ is gone.

So drive the request that sets the key: /oauth/authorize?partner=…&company=… for partner/company (arrive_via_partner in :existing_doorkeeper_app), /session/new?return_to=… for return_to, a bike's recovery link for recovery_link_token, a page render as an organization member for passive_organization_id. Where a param reaches the same code — return_to is read from params too — prefer the param over a priming request. spec/requests/sessions_request_spec.rb and spec/requests/users_request_spec.rb are the patterns.

A component's own markup is tested in its component spec

What a component decides about its markup — a label, a placeholder, a class, whether a field renders at all — belongs in spec/components/**/component_spec.rb, not in the request spec for a page that happens to render it. Request specs cover the request: status, redirects, what was saved, what the page is wired to.

A component having no spec yet isn't a reason to put it in the request spec instead. spec/components/pages/register/views/step2/component_spec.rb is the pattern, including the render_x helper that reloads the record so an object updated mid-example isn't answered from the copy the previous render left behind.

No stylesheet loads here, so Capybara calls anything hidden by a class visible. have_no_field/visible: false against a tw:hidden wrapper fails with "found 1 match", which reads as the component rendering the wrong thing. Assert the wrapper instead — page.find("form div.tw\\:hidden input[name='additional']", visible: :all) — and leave real invisibility to a :js system spec.

render_in_view_context takes its subjects as method arguments

A component needing a form_builder renders inside render_in_view_context { form_for … }, which instance_execs its block in the view context — so let values aren't in scope and a bare organization raises NameError. Wrap it in a def rendered_component(organization) and call that from the let(:component); the block closes over the method's locals. spec/components/pages/admin/organizations/form/wrapper/component_spec.rb is the pattern.

display_dev_info? is false in test, so nothing it gates is verifiable

ControllerHelpers#display_dev_info? opens with !Rails.env.test?. Every only-dev-visible block it wraps is unrendered in the suite, so threading the flag through a component — a wrong default, a missed hop — passes green and is wrong only in development. Reading the call sites isn't enough either — it can't show that a UI::Table cell block is instance_exec'd, so an @ivar in one resolves against the table and is always nil. Check it in the browser signed in as dev@bikeindex.org; the flag needs developer? and MiniProfiler, so the superadmin banner button won't do it.

log_in stubs the auth lookup, so it can't answer whether a session ends — or whether a user is confirmed

spec/support/request_spec_helpers.rb's log_in (and every :request_spec_logged_in_as_* context) stubs User.from_auth to return the user, so the cookie is never read and the session outlives anything done to that user — deleting, banning, rotating their auth_token.

The stub also answers User.unconfirmed.from_auth, so unconfirmed_current_user is always present. Sessionable#skip_if_signed_in asks that before it asks whether the user is confirmed, so every request it guards — /session/new, /session/magic_link, /users/new — redirects to please_confirm_email under log_in, whatever the user is.

Either one means signing in for real: include_context :request_spec_signed_in_for_real, which posts real credentials and takes a let(:user) override. spec/requests/sessions_request_spec.rb's "deleted after signing in" and its describe "destroy" are the patterns.

VCR cassettes: never hand-edit, always re-record

Never open a cassette and change it. Not a URL, not a token, not a timestamp, not an interaction — no matter how small or how obviously right the edit looks. A cassette is a recording of what a real service actually said; an edited one asserts something no service ever returned, and the spec passes against a fiction. This has no exceptions.

The only way a cassette changes is a spec run that records it:

  • Stale or wrong contents — rm the file and run the spec. VCR writes it from scratch, holding exactly the requests the spec makes now. Re-recording without deleting only writes back the requests still being made, so interactions the spec has stopped making survive forever — and because VCR times re_record_interval from the cassette's oldest interaction, one stale entry re-triggers re-recording on every run.
  • Cassette missing for a resource that doesn't exist yet — leave it absent and the spec red. Don't fabricate one.
  • Secrets — spec/rails_helper.rb's filter_sensitive_data and before_record handle them. Add the key there; don't scrub the file by hand.

Commit what a run re-records. A modified cassette is a re-recording, not unrelated churn — cassettes carry a re_record_interval and are meant to update. Commit it on the branch you're on, whatever that branch is about. Never git checkout it away to keep a diff focused.

git status after a spec run is the only signal; a run that re-records prints nothing.

Never write WebMock.stub_request. HTTP in a spec is a cassette, with no exceptions. A stub asserts what you imagined a service returns; a cassette records what it actually returned, which is the same reason cassettes are never hand-edited. The handful of WebMock.stub_request calls still in spec/ are legacy — existing usage is not a precedent to copy.

Stubbing ENV

Never partial-mock ENV with allow(ENV).to receive(:[]).and_call_original — it makes every subsequent ENV[...] lookup go through RSpec's message router, which is slow and easy to break by forgetting a .with(...) branch.

Use stub_const against a merged hash instead:

Good

stub_const("ENV", ENV.to_hash.merge("STRIPE_SECRET_KEY" => "sk_test_123"))

Bad

allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("STRIPE_SECRET_KEY").and_return("sk_test_123")

Drain Sidekiq jobs, don't run them inline

Run enqueued jobs by draining them in the default fake mode — SomeJob.drain for one job, Sidekiq::Job.drain_all for everything (clear first with Sidekiq::Job.clear_all when earlier setup left jobs queued). Don't wrap the exercise in Sidekiq::Testing.inline!. Draining lets the request finish and commit before the jobs run, against that committed state — the way production does it — and keeps the test from silently pulling in every cascading job.

Always fix failing tests

Fix every failing test, even ones that were already failing on main. Confirming a failure pre-dates your branch (via git stash or checking out main) explains what broke — not whether you fix it. You fix it.

Don't weaken assertions to make a failing test pass

Reproduce the failure and find out what changed before touching the expectation. Changing an expected value to whatever now renders, loosening eq to include, dropping a count:, or deleting the assertion with a "looks unrelated" handwave all erase signal rather than fix anything. Fix the code if the original assertion was right; update it — with a comment — if the behaviour intentionally changed. The fixing-flaky-failures skill has the same rule for the intermittent case, where it is absolute.

Match a target attributes hash, not one attribute at a time

When you're checking several fields on the same object or response, build one expected-attributes hash and assert against it in a single matcher. Don't write a chain of one-attribute-per-line expects.

  • Object (ActiveRecord, plain Ruby): expect(record).to have_attributes(target_attributes)
  • Hash (JSON response, parsed body): expect(hash).to eq(target.as_json) for full match, or expect(hash).to include(target_attributes) for partial.

This collapses what would be 4 brittle assertions into 1, makes the contract visible at a glance, and gives a single readable diff when something changes. It also avoids the trap of weak per-field assertions like expect(x).to be_present or expect(url).not_to include("blank.png") standing in for "the right value" — match the value directly.

Good

target_attributes = {kind: "found", impounded_description: "Some description"}
expect(impound_record).to have_attributes(target_attributes)

expect(json_result["memberships"]).to eq([target_membership.as_json])

Bad

expect(impound_record.kind).to eq("found")
expect(impound_record.impounded_description).to be_present
expect(impound_record.impounded_description).to eq("Some description")

logo_url = json_result["memberships"].first["organization_logo_url"]
expect(logo_url).to be_present
expect(logo_url).not_to include("blank.png")
expect(logo_url).to eq(organization.avatar_url)

Structuring with context and let

Use context and let to isolate what varies between examples. Each it block should live in a context that names the condition, with let overrides for only what differs in that case. Avoid repeating setup across sibling it blocks.

Good

describe "show_bulk_import?" do
  let(:organization) { FactoryBot.build(:organization, pos_kind:) }
  let(:pos_kind) { "no_pos" }

  it "is falsey" do
    expect(organization.show_bulk_import?).to be_falsey
  end

  context "when ascend" do
    let(:pos_kind) { "ascend_pos" }

    it "is truthy" do
      expect(organization.show_bulk_import?).to be_truthy
    end
  end

  context "when broken_ascend_pos" do
    let(:pos_kind) { "broken_ascend_pos" }
    it "is truthy" do
      expect(organization.show_bulk_import?).to be_truthy
    end
  end

  context "when lightspeed_pos" do
    let(:pos_kind) { "lightspeed_pos" }
    it "is truthy" do
      expect(organization.show_bulk_import?).to be_falsey
    end
  end

  context "when feature show_bulk_import_impound" do
    let(:organization) { FactoryBot.build(:organization_with_organization_features, enabled_feature_slugs: ["show_bulk_import_impound"]) }
    it "is truthy" do
      expect(organization.show_bulk_import?).to be_falsey
    end
  end
end

Bad

it "returns truthy for show_bulk_import?" do
  organization = FactoryBot.create(:organization, pos_kind: "ascend_pos")
  expect(organization.show_bulk_import?).to be_truthy
end
it "returns truthy when feature is included" do
  organization = FactoryBot.create(:organization)
  allow(organization).to receive(:any_enabled?) { true }
  expect(organization.show_bulk_import?).to be_truthy
end

The bad version repeats setup, mocks the object, and doesn't communicate what each case represents.

One example per distinct setup — combine same-setup it blocks

context/let/before isolate what varies. The corollary runs the other way: if two sibling it blocks share the same setup — no differing context, before, or let override between them — collapse them into one example. Each distinct setup earns exactly one it; put all of that setup's assertions (and all of its requests/renders) in that single block.

This is the same instinct as "everything making the same request should be in a single test", generalized: splitting same-setup assertions across sibling it blocks re-runs identical setup (factories, HTTP requests, renders) once per block for zero isolation benefit, and scatters one logical behavior across the file. Two it blocks that differ only in the request params or the assertion — with identical lets and no before between them — are one example.

After writing a spec, scan each context/describe: if it holds multiple it blocks and they don't each sit behind a distinct context/before/let, merge them.

Not when the first request changes what the next one does. Same setup isn't the same starting state once a request has run: signing in consumes session[:return_to], and session[:discourse_redirect] is set with ||= so a second arrival can't replace the first. Both merges pass review and go red. Leave those as sibling contexts with a let for what differs, and say in a comment why they can't share one example — otherwise the next cleanup pass merges them again.

A helper that reads the response has to be a def, not a let. One example making several requests is exactly where a let that parses response.body bites: it memoizes the first response and every later assertion re-reads it, so the example fails while the code is right (or worse, passes while the code is wrong). def re-evaluates.

Good

context "superuser" do
  let(:current_user) { FactoryBot.create(:superuser) }

  it "offers every view and renders the owner and org-limited perspectives" do
    get "#{base_url}/#{bike.id}"
    expect(body_text).to match("View as owner of bike")

    get "#{base_url}/#{bike.id}", params: {view_as: "owner"}
    expect(body_text).to match("Your bike")

    get "#{base_url}/#{bike.id}", params: {view_as: "#{org.to_param}.limited"}
    expect(body_text).to match("Limited")
  end
end

Bad

context "superuser" do
  let(:current_user) { FactoryBot.create(:superuser) }   # re-created for every it below

  it "offers every view" do
    get "#{base_url}/#{bike.id}"
    expect(body_text).to match("View as owner of bike")
  end
  it "renders the owner view" do
    get "#{base_url}/#{bike.id}", params: {view_as: "owner"}
    expect(body_text).to match("Your bike")
  end
  it "renders an org panel as limited" do
    get "#{base_url}/#{bike.id}", params: {view_as: "#{org.to_param}.limited"}
    expect(body_text).to match("Limited")
  end
end

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

Read live production data from Bike Index through the admin OAuth token — Sidekiq and PgHero status, and the user-submitted bug reports — the same data as the cookie-gated dashboards, but agent-friendly JSON. Trigger when the user asks about production queue depth, job backlog, retries/dead jobs, running Sidekiq processes, or Postgres health (slow/blocked queries, index usage, unused/invalid indexes, connection counts, table/db sizes, vacuum/transaction-id danger) — and wants the *live* answer from production rather than logs or Honeybadger. Also trigger for anything about bug reports users have emailed in: what's been reported, searching/filtering them by status, tag or membership, or tagging one / linking it to the PR that fixes it. Also trigger when a request returns 401/expired and the AdminData token needs refreshing/re-authorizing. Not for reading log files (use production-log-inspection) or aggregated exception triage (bin/binx_hb).

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

How to fix a test that fails intermittently in Bike Index — one that passes locally but fails on CI, fails on one shard, passes on re-run, or is already tagged `:flaky`. **Read this before touching any spec that is failing intermittently, and before adding, raising, or relying on a `flaky:` tag or a `wait:` bump.** Trigger on a CI failure the user calls flaky, unreliable, intermittent, "green locally", "passes on retry", "fix CI", or a pasted `gh run`/Actions URL whose failure isn't reproducible; also whenever you catch yourself about to delete, skip, loosen, or retry an assertion to make a red build go green. **Equally: any spec that fails intermittently while you verify your own work** — deciding whether your change caused a flake, or whether to ship past one, is this skill's problem too, and it arrives with no CI run, no `:flaky` tag, and nobody but you calling it flaky.

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

Bike Index's frontend conventions — Tailwind class prefixing (`tw:`), the standard `twinput`/`twlabel`/`twlink` form/link classes, the `number_display` helper for numbers, `Counts` for site-wide figures (bikes registered, recoveries, organizations), the UI component library rule (every button is `UI::Button`/`UI::ButtonLink`, every typeahead/autocomplete is `UI::Forms::Combobox`, every FAQ is `UI::Accordion`, never hand-rolled markup), that **helpers are deprecated — render a view component taking full keyword arguments instead of adding or extending one**, ViewComponent rules (when to write a partial instead, keyword arguments, instance variables, `helpers.` prefix in templates), and `UI::Time::Component` for every date/time. Trigger when adding or modifying views (`.html.erb`), view components, Stimulus controllers, Tailwind classes, image or icon files under `app/assets/images` (which usually already exist), or any frontend code that touches styling or interactivity — including admin screens, whose unlayered legacy CSS inverts several of these rules. Stimulus.js is the JavaScript framework; SCSS and CoffeeScript files exist but are deprecated.

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

Capture desktop+mobile screenshots of Bike Index pages from the local `bin/dev` server via Playwright MCP, with a seeded-user identity gate that keeps PII out of uploaded images. Use whenever a task needs screenshots of local pages — PR documentation, bug repros, before/after comparisons across branches, design review, demos — including mid-interaction states like an open dropdown, a modal showing, a form mid-fill, or a hover. Use it even when the user just says "grab a screenshot" or "show me what this looks like" without naming Playwright. For a component that only renders under an env var / feature flag / hard-to-reach state (e.g. the review-app banner), screenshot its ViewComponent/Lookbook preview URL instead of a full page. **Also read the filename rule here before any `mcp__playwright__browser_take_screenshot` call**, including a one-off capture of some other site — it's what keeps PNGs out of the working tree.

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

Embed a local image file into an existing GitHub PR — either in the PR body or as a comment. Trigger when a request pairs a local image (screenshot, .png/.jpg, CleanShot capture, before/after) with an existing PR (by #number, URL, branch name, or "the open PR"), regardless of verb — attach, embed, add, put, post, drop, show, document. Also covers visually documenting test runs, bug repros, UI states, or CI failures on an existing PR. Uploads through `gh`'s `--attach` flag — except in the Claude Code web sandbox, which has no `gh` and hosts images through the PR branch's history instead. It also owns the PR's one `## Screenshots` comment — finding, creating, editing and verifying it — so other workflows (the `pr` skill's screenshot phase) hand it a composed body that references local image paths, and it posts it.

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

Investigate and fix a specific Honeybadger exception in the Bike Index app — pull the fault, read its backtrace, find the offending code, write the fix. Trigger whenever a request names one Honeybadger error: a pasted fault URL (`app.honeybadger.io/projects/…/faults/…`), a fault ID, an error class plus "in production" (`Vips::Error`, `NoMethodError in AfterStolenRecordSaveJob`), or phrasings like "fix this Honeybadger error", "why is this erroring in prod", "what's causing this exception", "look at this HB fault". Use `bin/binx_hb` for fault and notice detail; it is the only Honeybadger reader this repo uses. Not for a broad production health sweep across many sources, and not for reading raw log files (that's production-log-inspection).

中文简介正在准备中。当前显示原文说明。

bikeindex/bike_index3082026年10月29日 更新

查看 bikeindex 的全部技能

报告此技能的问题