test_budget 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb6e4e3b3722807341b35ea3446e54c5bf989295702c4670388a8c7e337fff1d
4
- data.tar.gz: 6ec3ab375a2c22c6b57659b8931b3aef08019e321ea98aca4f9db8e5c681cd2c
3
+ metadata.gz: 0e19638c3b0f42996339e6a676005e07193ee34017b67c636f241ec04c91b59d
4
+ data.tar.gz: 68fff54ed7f1f4e888e18bb15db65888de38b5c76e6d5ff8fc3188cafdeb83d0
5
5
  SHA512:
6
- metadata.gz: '08c2a82158b3202c4fa5353d59bb98e0990d3f5d9cd71ea1bfdbcacb399565c8b60e6448b5277f7342e6f8166520c6d684e204a23f661511c6a3c641b7d08984'
7
- data.tar.gz: fff5a694e1d95454a6eb95343b981b470b936454e6c2286aa810c7e8f2eca24f8de6dc77debc9308b60e3bd43f5cddf46336871f599bc2c3ead542f0e2a2354e
6
+ metadata.gz: ab0d1bef70892fa3ae89a504e380498307e97646b2f0a98dbd8ec20bd5cb858d1e99b5725fd043d7cfecf822cadf5a6e9c0a1e8760fe89791057b09916f3d0d6
7
+ data.tar.gz: 5ef882443f34709c32785eb39ca669ea50a46fafee70e173ceaae534cb2da30845b53ba893d79554670dac5a30b63e9f1ac55263fc2e43029ed7082e049abac1
data/.test_budget.yml ADDED
@@ -0,0 +1,4 @@
1
+ timings_path: tmp/test_timings.json
2
+
3
+ suite:
4
+ max_duration: 1
data/README.md CHANGED
@@ -1,39 +1,267 @@
1
- # TestBudget
1
+ # Test Budget
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ > Prevent slow tests from creeping into your suite.
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/test_budget`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ Test suites get slow one test at a time. By the time you notice, your CI takes
6
+ 40 minutes and nobody wants to touch it.
6
7
 
7
- ## Installation
8
+ Test Budget is **a linter for test performance**. It reads your test results
9
+ after the run, checks durations against configured budgets, and fails if
10
+ anything goes over.
8
11
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
12
+ It doesn't change how your tests run. It just tells you when they're too slow
13
+ before it gets worse.
10
14
 
11
- Install the gem and add to the application's Gemfile by executing:
15
+ ## Install
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "test_budget", github: "thoughtbot/test_budget"
21
+ ```
22
+
23
+ > [!NOTE]
24
+ > Test Budget currently supports **RSpec only**. Minitest support is not yet available.
25
+
26
+ ## Quick start
27
+
28
+ Generate a starter config from an existing RSpec JSON results file:
29
+
30
+ ```bash
31
+ bundle exec test_budget init tmp/test_timings.json
32
+ ```
33
+
34
+ When given a results file, `init` derives budgets from your actual data (99th
35
+ percentile + 10% tolerance, rounded to the nearest 0.5s). If you don't have a
36
+ results file yet, run without arguments to generate a config with Rails
37
+ defaults:
38
+
39
+ ```bash
40
+ bundle exec test_budget init
41
+ ```
42
+
43
+ Use `--force` to overwrite an existing `.test_budget.yml`.
44
+
45
+ `estimate` is an alias for `init`. Use whichever name feels right:
46
+
47
+ ```bash
48
+ bundle exec test_budget estimate tmp/test_timings.json
49
+ ```
50
+
51
+ Then run the audit after your tests:
52
+
53
+ ```bash
54
+ bundle exec test_budget audit
55
+ ```
56
+
57
+ Example output:
58
+
59
+ ```
60
+ Test budget: 1 violation(s) found
61
+
62
+ 1) spec/system/signup_spec.rb -- creates account (11.20s) exceeds system limit (6.00s)
63
+ To allowlist, run:
64
+ bundle exec test_budget allowlist spec/system/signup_spec.rb:15 --reason "<reason>"
65
+ ```
66
+
67
+ ## RSpec JSON results file
68
+
69
+ > [!TIP]
70
+ > You can skip this step and run `test_budget init` without a results
71
+ > file to get started with Rails defaults right away.
72
+
73
+ Add to your RSpec configuration or CI command:
74
+
75
+ ```bash
76
+ bundle exec rspec --format json --out tmp/test_timings.json
77
+ ```
78
+
79
+ Or combine with your usual formatter:
80
+
81
+ ```bash
82
+ bundle exec rspec --format progress --format json --out tmp/test_timings.json
83
+ ```
84
+
85
+ ### Parallel test runners
86
+
87
+ If you use [`parallel_tests`][parallel_tests],
88
+ `$TEST_ENV_NUMBER` in command arguments is replaced per worker (empty string for
89
+ worker 1, `2` for worker 2, etc.). Use it to write a separate output file per
90
+ worker:
12
91
 
13
92
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
93
+ bundle exec parallel_rspec -- --format json --out 'tmp/test_timings$TEST_ENV_NUMBER.json'
94
+ ```
95
+
96
+ This produces `test_timings.json`, `test_timings2.json`, `test_timings3.json`,
97
+ etc. Then set your `timings_path` to a glob pattern:
98
+
99
+ ```yaml
100
+ timings_path: "tmp/test_timings*.json"
15
101
  ```
16
102
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
103
+ If you use [`flatware`][flatware], each worker appends its results to the same
104
+ output file. Test Budget handles this automatically:
18
105
 
19
106
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
107
+ flatware rspec --format json --out tmp/test_timings.json
21
108
  ```
22
109
 
23
- ## Usage
110
+ ## Configuration
24
111
 
25
- TODO: Write usage instructions here
112
+ The `init` command creates a `.test_budget.yml` in your project root. You can
113
+ also create one manually:
26
114
 
27
- ## Development
115
+ ```yaml
116
+ timings_path: tmp/test_timings.json
28
117
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
118
+ suite:
119
+ max_duration: 300 # seconds
30
120
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
121
+ per_test_case:
122
+ default: 2
123
+ system: 6
124
+ request: 3
125
+ model: 1.5
32
126
 
33
- ## Contributing
127
+ allowlist:
128
+ - test_case: "spec/services/invoice_pdf_spec.rb -- generates PDF with line items"
129
+ reason: "PDF generation is inherently slow, tracking in JIRA-1234"
130
+ expires_on: "2025-06-01"
131
+ ```
132
+
133
+ - **`timings_path`** (required) — path (or glob pattern) to the RSpec JSON output file(s).
134
+ - **`suite.max_duration`** — total duration budget for the entire suite.
135
+ - **`per_test_case.default`** — default per-test limit. Applies to any type without a specific limit.
136
+ - **`per_test_case.<type>`** — per-test limit for a specific type. Types are inferred from file paths by singularizing the directory name (`spec/models/` -> `model`, `spec/features/` -> `feature`, `spec/system/` -> `system`, etc).
137
+ - **`allowlist`** — known slow tests to skip. Each entry requires an `expires_on` date (YYYY-MM-DD). Expired entries stop exempting their tests. Use this as a temporary escape hatch, not a permanent solution.
138
+
139
+ > [!IMPORTANT]
140
+ > At least one limit (`suite.max_duration`, `per_test_case.default`, or a type-specific limit) must be configured.
34
141
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/test_budget.
142
+ ## Audit
143
+
144
+ ```bash
145
+ bundle exec test_budget audit
146
+ ```
147
+
148
+ Use `--budget` to point to a different config file:
149
+
150
+ ```bash
151
+ bundle exec test_budget audit --budget config/test_budget.yml
152
+ ```
153
+
154
+ Use `--tolerant` to apply a 10% tolerance to all limits. This is useful on
155
+ shared CI infrastructure where CPU contention causes small fluctuations in test
156
+ durations:
157
+
158
+ ```bash
159
+ bundle exec test_budget audit --tolerant
160
+ ```
161
+
162
+ With `--tolerant`, a test only fails if it exceeds the limit by more than 10%
163
+ (e.g., a 5s limit becomes an effective 5.5s limit).
164
+
165
+ Exit code is `0` when all tests are within budget, `1` when there are violations.
166
+
167
+ ## Allowlist
168
+
169
+ You can allowlist individual tests via the CLI:
170
+
171
+ ```bash
172
+ bundle exec test_budget allowlist spec/models/user_spec.rb:10 --reason "Tracking in JIRA-1234"
173
+ ```
174
+
175
+ Entries are created with a 60-day expiration by default. Edit the `expires_on`
176
+ date in the YAML file if you need a different window.
177
+
178
+ ### Pruning obsolete entries
179
+
180
+ Over time, allowlisted tests may be fixed or removed. Use `prune` to clean up
181
+ entries that are no longer needed:
182
+
183
+ ```bash
184
+ bundle exec test_budget prune
185
+ ```
186
+
187
+ This removes **stale** entries (test no longer exists) and **unnecessary** entries
188
+ (test is now within budget). The `audit` command also warns about these entries
189
+ so you know when it's time to prune.
190
+
191
+ ### Example output
192
+
193
+ ```
194
+ Test budget: 2 violation(s) found
195
+
196
+ 1) spec/models/user_spec.rb -- User#full_name (2.50s) exceeds model limit (1.00s)
197
+ To allowlist, run:
198
+ bundle exec test_budget allowlist spec/models/user_spec.rb:10 --reason "<reason>"
199
+
200
+ 2) Suite total (650.00s) exceeds limit (600.00s)
201
+ ```
202
+
203
+ ## CI integration
204
+
205
+ Run the audit after your test suite:
206
+
207
+ ```yaml
208
+ # .github/workflows/ci.yml
209
+ - run: bundle exec rspec --format progress --format json --out tmp/test_timings.json
210
+ - run: bundle exec test_budget audit
211
+ ```
212
+
213
+ The second step fails the build if any test exceeds its budget.
214
+
215
+ ## I have violations. Now what?
216
+
217
+ Violations mean tests are slower than you decided they should be. You have
218
+ options:
219
+
220
+ - **Make the tests faster.** This is the best option. Look for unnecessary
221
+ setup, N+1 queries, slow external calls that could be stubbed. Can the same
222
+ behavior be exercised with a faster test type? (e.g. system -> request,
223
+ request -> model)
224
+ - **Split the work.** A test doing too much can often be broken into
225
+ focused scenarios.
226
+ - **Parallelize.** Tools like [`parallel_tests`][parallel_tests] and
227
+ [`flatware`][flatware] reduce wall time without changing individual test
228
+ durations, but consider also setting per-test budgets to keep individual tests
229
+ honest.
230
+ - **Upgrade infrastructure.** Faster CI (or developer) machines buy time.
231
+ - **Allowlist temporarily.** If a fix isn't immediate, add the test to the
232
+ allowlist and create a ticket. This keeps the budget enforced for everything
233
+ else.
234
+
235
+ The goal isn't zero violations on day one. It's to stop the bleeding and make
236
+ test performance visible.
36
237
 
37
238
  ## License
38
239
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
240
+ The gem is available as open source under the terms of the [MIT
241
+ License](https://opensource.org/licenses/MIT).
242
+
243
+ ## Code of Conduct
244
+
245
+ Everyone interacting in the Test Budget project's codebases, issue trackers,
246
+ chat rooms and mailing lists is expected to follow the [code of
247
+ conduct](https://github.com/thoughtbot/test_budget/blob/main/CODE_OF_CONDUCT.md).
248
+
249
+ <!-- START /templates/footer.md -->
250
+
251
+ ## About thoughtbot
252
+
253
+ ![thoughtbot](https://thoughtbot.com/thoughtbot-logo-for-readmes.svg)
254
+
255
+ This repo is maintained and funded by thoughtbot, inc. The names and logos for
256
+ thoughtbot are trademarks of thoughtbot, inc.
257
+
258
+ We love open source software! See [our other projects][community]. We are
259
+ [available for hire][hire].
260
+
261
+ [community]: https://thoughtbot.com/community?utm_source=github&utm_medium=readme&utm_campaign=test_budget
262
+ [hire]: https://thoughtbot.com/hire-us?utm_source=github&utm_medium=readme&utm_campaign=test_budget
263
+
264
+ <!-- END /templates/footer.md -->
265
+
266
+ [parallel_tests]: https://github.com/grosser/parallel_tests
267
+ [flatware]: https://github.com/briandunn/flatware
data/Rakefile CHANGED
@@ -6,3 +6,4 @@ require "rspec/core/rake_task"
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  task default: :spec
9
+ task build: :spec
data/exe/test_budget ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "test_budget"
5
+
6
+ exit TestBudget::CLI.new.call(ARGV)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module TestBudget
6
+ class Allowlist
7
+ def initialize(raw_entries = [])
8
+ @entries = raw_entries.map do |entry|
9
+ Entry.new(
10
+ test_case_key: entry["test_case"],
11
+ reason: entry["reason"],
12
+ expires_on: parse_date(entry["expires_on"])
13
+ )
14
+ end
15
+ end
16
+
17
+ def allowed?(key)
18
+ @entries.find { |e| e.matches?(key) }
19
+ end
20
+
21
+ def add(key, reason:, expires_on:)
22
+ raise Error, "#{key} is already allowlisted" if allowed?(key)
23
+
24
+ entry = Entry.new(test_case_key: key, reason: reason, expires_on: expires_on)
25
+ @entries << entry
26
+ entry
27
+ end
28
+
29
+ def prune(test_run, budget)
30
+ removed, @entries = @entries.partition { |entry| entry.obsolete?(test_run, budget) }
31
+ removed
32
+ end
33
+
34
+ def to_a
35
+ @entries.dup
36
+ end
37
+
38
+ private
39
+
40
+ def parse_date(raw)
41
+ Date.parse(raw.to_s)
42
+ rescue Date::Error, TypeError
43
+ raise Error, "expires_on is required and must be a valid date (YYYY-MM-DD)"
44
+ end
45
+
46
+ Entry = Data.define(:test_case_key, :reason, :expires_on) do
47
+ def expired? = Date.today > expires_on
48
+
49
+ def matches?(key)
50
+ test_case_key == key
51
+ end
52
+
53
+ def obsolete?(test_run, budget)
54
+ test_case = test_run.test_cases.find { |tc| tc.key == test_case_key }
55
+
56
+ if test_case.nil?
57
+ :stale
58
+ elsif test_case.within?(budget)
59
+ :unnecessary
60
+ end
61
+ end
62
+
63
+ def to_h
64
+ {"test_case" => test_case_key, "reason" => reason, "expires_on" => expires_on.to_s}
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ class Audit
5
+ Result = Data.define(:violations, :warnings) do
6
+ def passed? = violations.empty?
7
+ end
8
+
9
+ def initialize(budget_path:, tolerant: false)
10
+ @budget_path = budget_path
11
+ @tolerant = tolerant
12
+ end
13
+
14
+ def perform
15
+ budget = Budget.load(@budget_path)
16
+ test_run = Parser::Rspec.parse(budget.timings_path)
17
+ result = Auditor.new(budget, tolerant: @tolerant).audit(test_run)
18
+ Reporter.new(budget_path: @budget_path).report(result)
19
+
20
+ result
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ class Auditor
5
+ TOLERANCE = 0.1
6
+
7
+ def initialize(budget, tolerant: false)
8
+ @budget = tolerant ? budget.inflate_by(TOLERANCE) : budget
9
+ end
10
+
11
+ def audit(test_run)
12
+ Audit::Result.new(
13
+ violations: violations_for(test_run),
14
+ warnings: warnings_for(test_run)
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def violations_for(test_run)
21
+ violations = test_run.test_cases.filter_map { |tc| tc.violation_for(@budget) }
22
+ violations << test_run.over?(@budget)
23
+ violations.compact
24
+ end
25
+
26
+ def warnings_for(test_run)
27
+ @budget.allowlist.to_a.filter_map do |entry|
28
+ kind = entry.obsolete?(test_run, @budget)
29
+ Warning.new(entry: entry, kind: kind) if kind
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module TestBudget
6
+ class Budget
7
+ class Estimate
8
+ DEFAULT_TIMINGS_PATH = "tmp/test_timings.json"
9
+ TOLERANCE = 0.10
10
+ PER_TEST_CASE_DEFAULTS = {"default" => 3, "system" => 6, "request" => 3, "model" => 1.5}.freeze
11
+
12
+ def initialize(timings_path: nil, force: false)
13
+ @timings_path = timings_path
14
+ @force = force
15
+ end
16
+
17
+ def generate
18
+ guard_existing_config!
19
+
20
+ if @timings_path
21
+ generate_from_results
22
+ else
23
+ generate_defaults
24
+ end
25
+
26
+ puts "Created #{DEFAULT_BUDGET_PATH}"
27
+ end
28
+
29
+ private
30
+
31
+ def guard_existing_config!
32
+ return if @force || !File.exist?(DEFAULT_BUDGET_PATH)
33
+
34
+ raise TestBudget::Error, "#{DEFAULT_BUDGET_PATH} already exists. Use --force to overwrite."
35
+ end
36
+
37
+ def generate_from_results
38
+ test_run = Parser::Rspec.parse(@timings_path)
39
+ suite_budget = (test_run.suite_duration * (1 + TOLERANCE)).ceil
40
+ per_test_case_limits = derive_per_test_case(test_run.test_cases)
41
+
42
+ budget = build_budget(
43
+ timings_path: @timings_path,
44
+ suite_max_duration: suite_budget,
45
+ per_test_case_limits: per_test_case_limits
46
+ )
47
+ budget.save
48
+ end
49
+
50
+ def generate_defaults
51
+ build_budget(
52
+ timings_path: DEFAULT_TIMINGS_PATH,
53
+ per_test_case_limits: PER_TEST_CASE_DEFAULTS
54
+ ).save
55
+ end
56
+
57
+ def build_budget(timings_path:, per_test_case_limits:, suite_max_duration: nil)
58
+ types = per_test_case_limits.except("default").transform_keys(&:to_sym)
59
+
60
+ Budget.new(
61
+ path: DEFAULT_BUDGET_PATH,
62
+ timings_path: timings_path,
63
+ suite: Budget::Suite.new(max_duration: suite_max_duration),
64
+ per_test_case: Budget::PerTestCase.new(
65
+ default: per_test_case_limits["default"],
66
+ types: types
67
+ ),
68
+ allowlist: Allowlist.new
69
+ )
70
+ end
71
+
72
+ def derive_per_test_case(test_cases)
73
+ grouped = test_cases.group_by { |tc| tc.type.to_s }
74
+
75
+ PER_TEST_CASE_DEFAULTS.merge(
76
+ grouped.transform_values { |cases|
77
+ Statistics.p99(cases.map(&:duration), tolerance: TOLERANCE)
78
+ }
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ module TestBudget
7
+ class Budget < Data.define(:path, :timings_path, :suite, :per_test_case, :allowlist)
8
+ DEFAULT_EXPIRATION_DAYS = 60
9
+
10
+ Suite = Data.define(:max_duration)
11
+ PerTestCase = Data.define(:default, :types)
12
+
13
+ def self.estimate(...) = Estimate.new(...).generate
14
+
15
+ def self.load(path)
16
+ config = YAML.safe_load_file(path) || {}
17
+ suite_config = config["suite"] || {}
18
+ per_test_case_config = config["per_test_case"] || {}
19
+ types = per_test_case_config.except("default").transform_keys(&:to_sym)
20
+
21
+ budget = new(
22
+ path: path,
23
+ timings_path: config["timings_path"],
24
+ suite: Suite.new(max_duration: suite_config["max_duration"]),
25
+ per_test_case: PerTestCase.new(
26
+ default: per_test_case_config["default"],
27
+ types: types
28
+ ),
29
+ allowlist: Allowlist.new(config["allowlist"] || [])
30
+ )
31
+
32
+ raise TestBudget::Error, "timings_path is required in budget file" unless budget.timings_path
33
+ raise TestBudget::Error, "No limits configured. Set suite.max_duration or per_test_case limits" unless budget.limits_set?
34
+
35
+ budget
36
+ rescue Errno::ENOENT
37
+ raise TestBudget::Error, "Budget file not found: #{path}"
38
+ end
39
+
40
+ def exempt?(test_case)
41
+ entry = allowlist.allowed?(test_case.key)
42
+
43
+ entry && !entry.expired?
44
+ end
45
+
46
+ def limit_for(test_case)
47
+ per_test_case.types[test_case.type] || per_test_case.default
48
+ end
49
+
50
+ def add_to_allowlist(locator, reason:)
51
+ test_run = Parser::Rspec.parse(timings_path)
52
+ test_case = TestCase.find_by_location!(test_run.test_cases, locator)
53
+
54
+ allowlist.add(test_case.key, reason: reason, expires_on: Date.today + DEFAULT_EXPIRATION_DAYS).tap { save }
55
+ end
56
+
57
+ def prune_allowlist
58
+ test_run = Parser::Rspec.parse(timings_path)
59
+ removed = allowlist.prune(test_run, self)
60
+ save if removed.any?
61
+ removed
62
+ end
63
+
64
+ def inflate_by(percent)
65
+ factor = 1 + percent
66
+
67
+ with(
68
+ suite: Suite.new(max_duration: suite.max_duration&.*(factor)),
69
+ per_test_case: PerTestCase.new(
70
+ default: per_test_case.default&.*(factor),
71
+ types: per_test_case.types.transform_values { |v| v * factor }
72
+ )
73
+ )
74
+ end
75
+
76
+ def limits_set?
77
+ suite.max_duration || per_test_case.default || per_test_case.types.any?
78
+ end
79
+
80
+ def save
81
+ File.write(path, YAML.dump(to_h))
82
+ end
83
+
84
+ private
85
+
86
+ def to_h
87
+ deep_compact_blank(
88
+ "timings_path" => timings_path,
89
+ "suite" => {"max_duration" => suite.max_duration},
90
+ "per_test_case" => {
91
+ "default" => per_test_case.default,
92
+ **per_test_case.types.transform_keys(&:to_s)
93
+ },
94
+ "allowlist" => allowlist.to_a.map(&:to_h)
95
+ )
96
+ end
97
+
98
+ def deep_compact_blank(hash)
99
+ hash.each_with_object({}) do |(key, value), result|
100
+ value = deep_compact_blank(value) if value.is_a?(Hash)
101
+ next if value.nil?
102
+ next if value.respond_to?(:empty?) && value.empty?
103
+ result[key] = value
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "argument_parser"
5
+
6
+ module TestBudget
7
+ class CLI
8
+ def call(argv)
9
+ args = argv.dup
10
+
11
+ return print_help if args.empty? || (args & %w[--help -h]).any?
12
+ return print_version if (args & %w[--version -v]).any?
13
+
14
+ parsed = command_parser.parse!(args)
15
+
16
+ case parsed[:command]
17
+ in "audit" then run_audit(args)
18
+ in "allowlist" then run_allowlist(args)
19
+ in "prune" then run_prune(args)
20
+ in "init" then run_init(args)
21
+ in "help" then print_help
22
+ end
23
+ rescue ArgumentParser::ParseError, TestBudget::Error, OptionParser::MissingArgument => e
24
+ warn e.message
25
+ 1
26
+ end
27
+
28
+ private
29
+
30
+ def command_parser
31
+ ArgumentParser.build do
32
+ required :command, pattern: {"audit" => "audit", "allowlist" => "allowlist", "prune" => "prune",
33
+ "init" => "init", "estimate" => "init", "help" => "help"}
34
+ end
35
+ end
36
+
37
+ def print_help
38
+ puts help_text
39
+ 0
40
+ end
41
+
42
+ def print_version
43
+ puts "test_budget #{TestBudget::VERSION}"
44
+ 0
45
+ end
46
+
47
+ def help_text
48
+ <<~HELP
49
+ Usage: test_budget <command> [options]
50
+
51
+ Commands:
52
+ audit Check test results against budget
53
+ allowlist Exclude a test from budget checks
54
+ prune Remove obsolete allowlist entries
55
+ init Generate starter .test_budget.yml config
56
+ estimate Alias for init
57
+ help Show this help message
58
+
59
+ Options:
60
+ -h, --help Show this help message
61
+ -v, --version Show version
62
+ HELP
63
+ end
64
+
65
+ def run_audit(args)
66
+ budget_path = DEFAULT_BUDGET_PATH
67
+ tolerant = false
68
+
69
+ OptionParser.new do |opts|
70
+ opts.banner = "Usage: test_budget audit [options]"
71
+ opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
72
+ opts.on("--tolerant", "Apply 10% tolerance to limits") { tolerant = true }
73
+ end.parse!(args)
74
+
75
+ result = Audit.new(budget_path: budget_path, tolerant: tolerant).perform
76
+
77
+ result.passed? ? 0 : 1
78
+ end
79
+
80
+ def run_allowlist(args)
81
+ budget_path = DEFAULT_BUDGET_PATH
82
+ reason = nil
83
+
84
+ OptionParser.new do |opts|
85
+ opts.banner = "Usage: test_budget allowlist FILE:LINE --reason REASON [options]"
86
+ opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
87
+ opts.on("--reason REASON", "Reason for allowlisting") { |r| reason = r }
88
+ end.parse!(args)
89
+
90
+ locator = args.shift
91
+ raise Error, "--reason is required for allowlist" unless reason
92
+ raise Error, "locator (e.g., spec/file_spec.rb:10) is required" unless locator
93
+
94
+ entry = Budget.load(budget_path).add_to_allowlist(locator, reason: reason)
95
+ puts "Allowlisted: #{entry.test_case_key}"
96
+
97
+ 0
98
+ end
99
+
100
+ def run_prune(args)
101
+ budget_path = DEFAULT_BUDGET_PATH
102
+
103
+ OptionParser.new do |opts|
104
+ opts.banner = "Usage: test_budget prune [options]"
105
+ opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
106
+ end.parse!(args)
107
+
108
+ removed = Budget.load(budget_path).prune_allowlist
109
+
110
+ if removed.any?
111
+ puts "#{removed.size} obsolete allowlist #{(removed.size == 1) ? "entry" : "entries"} removed"
112
+ else
113
+ puts "No obsolete allowlist entries found"
114
+ end
115
+
116
+ 0
117
+ end
118
+
119
+ def run_init(args)
120
+ force = false
121
+
122
+ OptionParser.new do |opts|
123
+ opts.banner = "Usage: test_budget init [timings_file] [options]"
124
+ opts.on("--force", "Overwrite existing config") { force = true }
125
+ end.parse!(args)
126
+ timings_path = args.shift
127
+
128
+ Budget.estimate(timings_path: timings_path, force: force)
129
+
130
+ 0
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TestBudget
6
+ module Parser
7
+ module Rspec
8
+ extend self
9
+
10
+ def parse(pattern)
11
+ paths = Dir.glob(pattern)
12
+ raise TestBudget::Error, "No timing files found matching: #{pattern}" if paths.empty?
13
+
14
+ groups = paths.flat_map { |path| parse_file(path) }
15
+ TestRun.new(
16
+ test_cases: groups.flatten,
17
+ suite_duration: groups.map { |g| g.sum(&:duration) }.max || 0
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def parse_file(path)
24
+ read_json_objects(path).map do |data|
25
+ data["examples"].map do |example|
26
+ TestCase.new(
27
+ file: example["file_path"],
28
+ name: example["full_description"],
29
+ duration: example["run_time"],
30
+ status: example["status"],
31
+ line_number: example["line_number"]
32
+ )
33
+ end
34
+ end
35
+ end
36
+
37
+ def read_json_objects(path)
38
+ content = File.read(path)
39
+ [JSON.parse(content)]
40
+ rescue JSON::ParserError
41
+ parse_concatenated_json(content, path)
42
+ end
43
+
44
+ CONCATENATED_JSON_BOUNDARY = /(?<=\})(?=\{)/
45
+
46
+ def parse_concatenated_json(content, path)
47
+ content.split(CONCATENATED_JSON_BOUNDARY).map { |chunk| JSON.parse(chunk) }
48
+ rescue JSON::ParserError => e
49
+ raise TestBudget::Error, "Invalid JSON in #{path}: #{e.message}"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ class Reporter
5
+ def initialize(budget_path:)
6
+ @budget_path = budget_path
7
+ end
8
+
9
+ def report(result)
10
+ violations = result.violations
11
+
12
+ if violations.empty?
13
+ puts "Test budget: all clear"
14
+ else
15
+ puts "Test budget: #{violations.size} violation(s) found\n\n"
16
+
17
+ violations.each_with_index do |violation, index|
18
+ puts " #{index + 1}) #{violation.message}"
19
+
20
+ if (locator = violation.locator)
21
+ budget_flag = (@budget_path == ".test_budget.yml") ? "" : " --budget #{@budget_path}"
22
+ puts " To allowlist, run:"
23
+ puts " bundle exec test_budget allowlist #{locator}#{budget_flag} --reason \"<reason>\""
24
+ end
25
+ end
26
+ end
27
+
28
+ result.warnings.each do |warning|
29
+ warn "Warning: #{warning.message}\n\n"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ module Statistics
5
+ def self.p99(values, tolerance: 0) = percentile(0.99, values, tolerance: tolerance)
6
+
7
+ private_class_method def self.percentile(rank, values, tolerance: 0)
8
+ sorted = values.sort
9
+ n = sorted.size
10
+ return apply_tolerance(sorted[0], tolerance) if n == 1
11
+
12
+ index = rank * (n - 1)
13
+ lower = sorted[index.floor]
14
+ upper = sorted[index.ceil]
15
+ result = lower + (upper - lower) * (index - index.floor)
16
+
17
+ apply_tolerance(result, tolerance)
18
+ end
19
+
20
+ private_class_method def self.apply_tolerance(value, tolerance)
21
+ ceil_to_half(value * (1 + tolerance))
22
+ end
23
+
24
+ private_class_method def self.ceil_to_half(value)
25
+ (value * 2).ceil / 2.0
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ class TestCase < Data.define(:file, :name, :duration, :status, :line_number)
5
+ def initialize(file:, name:, duration:, status:, line_number:)
6
+ super(file: file.delete_prefix("./"), name: name, duration: duration, status: status, line_number: line_number)
7
+ end
8
+
9
+ IRREGULAR_TYPES = {
10
+ "policies" => :policy,
11
+ "factories" => :factory,
12
+ "queries" => :query
13
+ }.freeze
14
+
15
+ def type
16
+ dir = file[%r{^spec/([^/]+)/}, 1]
17
+ return :default unless dir
18
+
19
+ IRREGULAR_TYPES[dir] || dir.chomp("s").to_sym
20
+ end
21
+
22
+ def key
23
+ "#{file} -- #{name}"
24
+ end
25
+
26
+ def violation_for(budget)
27
+ return unless violates?(budget)
28
+
29
+ Violation.new(test_case: self, duration: duration, limit: budget.limit_for(self), kind: :per_test_case)
30
+ end
31
+
32
+ def violates?(budget) = over?(budget) && !exempted?(budget)
33
+
34
+ def over?(budget) = !within?(budget)
35
+
36
+ def within?(budget)
37
+ limit = budget.limit_for(self)
38
+ return true if limit.nil?
39
+
40
+ duration <= limit
41
+ end
42
+
43
+ def exempted?(budget)
44
+ budget.exempt?(self)
45
+ end
46
+
47
+ def self.find_by_location!(test_cases, locator)
48
+ file, line = parse_locator(locator)
49
+ match = test_cases.find { |tc| tc.file == file && tc.line_number == line }
50
+
51
+ raise Error, "No test case found at #{locator}" unless match
52
+
53
+ match
54
+ end
55
+
56
+ def self.parse_locator(locator)
57
+ m = locator.match(/\A(.+):(\d+)\z/)
58
+ raise Error, "Line number required in locator (e.g., spec/file_spec.rb:10)" unless m
59
+
60
+ [m[1], m[2].to_i]
61
+ end
62
+
63
+ private_class_method :parse_locator
64
+ end
65
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ class TestRun < Data.define(:test_cases, :suite_duration)
5
+ def over?(budget)
6
+ max = budget.suite.max_duration
7
+ return if max.nil?
8
+ return if suite_duration <= max
9
+
10
+ Violation.new(test_case: nil, duration: suite_duration, limit: max, kind: :suite)
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TestBudget
4
- VERSION = "0.0.0"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ Violation = Data.define(:test_case, :duration, :limit, :kind) do
5
+ def message
6
+ if kind == :suite
7
+ "Suite total time (%.2fs) exceeds limit (%.2fs)" % [duration, limit]
8
+ else
9
+ format("%s (%.2fs) exceeds %s limit (%.2fs)", test_case.key, duration, test_case.type, limit)
10
+ end
11
+ end
12
+
13
+ def locator
14
+ return nil if kind == :suite
15
+
16
+ "#{test_case.file}:#{test_case.line_number}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestBudget
4
+ Warning = Data.define(:entry, :kind) do
5
+ def message
6
+ reason_line = entry.reason ? "\nreason: #{entry.reason}" : ""
7
+ "obsolete allowlist entry (#{kind})\n#{entry.test_case_key}#{reason_line}"
8
+ end
9
+ end
10
+ end
data/lib/test_budget.rb CHANGED
@@ -1,8 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "test_budget/version"
4
+ require_relative "test_budget/allowlist"
5
+ require_relative "test_budget/test_case"
6
+ require_relative "test_budget/violation"
7
+ require_relative "test_budget/warning"
8
+ require_relative "test_budget/budget"
9
+ require_relative "test_budget/test_run"
10
+ require_relative "test_budget/auditor"
11
+ require_relative "test_budget/reporter"
12
+ require_relative "test_budget/parser/rspec"
13
+ require_relative "test_budget/audit"
14
+ require_relative "test_budget/statistics"
15
+ require_relative "test_budget/budget/estimate"
16
+ require_relative "test_budget/cli"
4
17
 
5
18
  module TestBudget
6
19
  class Error < StandardError; end
7
- # Your code goes here...
20
+
21
+ DEFAULT_BUDGET_PATH = ".test_budget.yml"
8
22
  end
metadata CHANGED
@@ -1,30 +1,65 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: test_budget
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matheus Richard
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: You have a time budget for tests. This gem enforces it.
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: argument_parser
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: Set a time budget for your tests (and your whole suite) and have it automatically
27
+ enforced in CI. No more slow tests sneaking into your suite!
13
28
  email:
14
29
  - matheusrichardt@gmail.com
15
- executables: []
30
+ executables:
31
+ - test_budget
16
32
  extensions: []
17
33
  extra_rdoc_files: []
18
34
  files:
35
+ - ".test_budget.yml"
19
36
  - CHANGELOG.md
20
37
  - LICENSE.txt
21
38
  - README.md
22
39
  - Rakefile
40
+ - exe/test_budget
23
41
  - lib/test_budget.rb
42
+ - lib/test_budget/allowlist.rb
43
+ - lib/test_budget/audit.rb
44
+ - lib/test_budget/auditor.rb
45
+ - lib/test_budget/budget.rb
46
+ - lib/test_budget/budget/estimate.rb
47
+ - lib/test_budget/cli.rb
48
+ - lib/test_budget/parser/rspec.rb
49
+ - lib/test_budget/reporter.rb
50
+ - lib/test_budget/statistics.rb
51
+ - lib/test_budget/test_case.rb
52
+ - lib/test_budget/test_run.rb
24
53
  - lib/test_budget/version.rb
54
+ - lib/test_budget/violation.rb
55
+ - lib/test_budget/warning.rb
56
+ homepage: https://github.com/thoughtbot/test_budget
25
57
  licenses:
26
58
  - MIT
27
- metadata: {}
59
+ metadata:
60
+ homepage_uri: https://github.com/thoughtbot/test_budget
61
+ source_code_uri: https://github.com/thoughtbot/test_budget
62
+ changelog_uri: https://github.com/thoughtbot/test_budget/blob/main/CHANGELOG.md
28
63
  rdoc_options: []
29
64
  require_paths:
30
65
  - lib