suture 0.4.0 → 0.5.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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +22 -0
- data/README.md +15 -6
- data/Rakefile +3 -3
- data/lib/suture/adapter/dictaphone.rb +38 -17
- data/lib/suture/adapter/log.rb +4 -0
- data/lib/suture/adapter/progress_bar.rb +13 -0
- data/lib/suture/config.rb +2 -1
- data/lib/suture/create/chooses_surgeon.rb +1 -1
- data/lib/suture/create/validates_plan.rb +6 -6
- data/lib/suture/delete.rb +6 -1
- data/lib/suture/error/observation_conflict.rb +2 -2
- data/lib/suture/error/verification_failed.rb +41 -20
- data/lib/suture/surgeon/no_op.rb +13 -2
- data/lib/suture/surgeon/observer.rb +9 -2
- data/lib/suture/util/compares_results.rb +18 -0
- data/lib/suture/util/scalpel.rb +30 -4
- data/lib/suture/value/observation.rb +14 -6
- data/lib/suture/value/plan.rb +4 -3
- data/lib/suture/value/result.rb +35 -0
- data/lib/suture/value/test_plan.rb +2 -1
- data/lib/suture/value/test_results.rb +8 -2
- data/lib/suture/verify/administers_test.rb +32 -0
- data/lib/suture/verify/tests_patient.rb +21 -19
- data/lib/suture/version.rb +1 -1
- data/lib/suture/wrap/logger.rb +15 -12
- data/lib/suture/wrap/sqlite.rb +7 -6
- data/suture.gemspec +2 -1
- metadata +20 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9ca2d3a0376378b6ead8f55b0f92e1ef20b73991
|
4
|
+
data.tar.gz: b506cf09899bfd1e372c088aaba8203a0f0c20aa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 30813362842d2a559181b248b08904187d96f78d6feac8b3662ef4a2e7dfa35a2cdda38f5b589184e531266574f4c1e7b23fb7fd68b749bd90d36f73f3a9d652
|
7
|
+
data.tar.gz: 94f1c2b749cadc2a1dc80ac962bc7bfee7d735cea0eae1940a49c09543466f167e25faa809915ae8077e70f34d93590cd1e9bee1d32278be0cd0c77c47ee1122
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,27 @@
|
|
1
1
|
# Change Log
|
2
2
|
|
3
|
+
## [Unreleased](https://github.com/testdouble/suture/tree/HEAD)
|
4
|
+
|
5
|
+
[Full Changelog](https://github.com/testdouble/suture/compare/v0.3.3...HEAD)
|
6
|
+
|
7
|
+
**Closed issues:**
|
8
|
+
|
9
|
+
- common test parent for unit tests [\#49](https://github.com/testdouble/suture/issues/49)
|
10
|
+
- rake test -\> rake unit w/ test as default [\#48](https://github.com/testdouble/suture/issues/48)
|
11
|
+
- Override flag to disable the seam / revert to old path [\#47](https://github.com/testdouble/suture/issues/47)
|
12
|
+
- Suture.verify :after\_subject hook [\#46](https://github.com/testdouble/suture/issues/46)
|
13
|
+
- Suture.create :call\_old\_on\_error flag [\#43](https://github.com/testdouble/suture/issues/43)
|
14
|
+
- Suture.create :on\_{new,old,subject}\_error handler [\#42](https://github.com/testdouble/suture/issues/42)
|
15
|
+
- Implement Suture.create :call\_both [\#41](https://github.com/testdouble/suture/issues/41)
|
16
|
+
- Suture.create :after\_old and :after\_new hooks [\#40](https://github.com/testdouble/suture/issues/40)
|
17
|
+
- Suture.create :raise\_on\_result\_mismatch [\#39](https://github.com/testdouble/suture/issues/39)
|
18
|
+
- Add code coverage / code climate [\#38](https://github.com/testdouble/suture/issues/38)
|
19
|
+
- implement changelog generator [\#37](https://github.com/testdouble/suture/issues/37)
|
20
|
+
- Validate plans [\#15](https://github.com/testdouble/suture/issues/15)
|
21
|
+
|
22
|
+
## [v0.3.3](https://github.com/testdouble/suture/tree/v0.3.3) (2016-08-23)
|
23
|
+
[Full Changelog](https://github.com/testdouble/suture/compare/v0.3.2...v0.3.3)
|
24
|
+
|
3
25
|
## [v0.3.2](https://github.com/testdouble/suture/tree/v0.3.2) (2016-08-23)
|
4
26
|
[Full Changelog](https://github.com/testdouble/suture/compare/v0.3.1...v0.3.2)
|
5
27
|
|
data/README.md
CHANGED
@@ -34,7 +34,7 @@ you'd like to change. A good seam is:
|
|
34
34
|
|
35
35
|
* easy to invoke in isolation
|
36
36
|
* takes arguments, returns a value
|
37
|
-
* eliminates (or at least minimizes) side effects
|
37
|
+
* eliminates (or at least minimizes) side effects (for more on side effects, see [this tutorial](https://semaphoreci.com/community/tutorials/isolate-side-effects-in-ruby))
|
38
38
|
|
39
39
|
Then, to create a seam, typically we create a new unit to house the code that we
|
40
40
|
excise from its original site, and then we call it. This adds a level of
|
@@ -150,8 +150,8 @@ the test environment has the data needed to behave the same way as when it was
|
|
150
150
|
recorded (it may be appropriate to take a snapshot of the database before you
|
151
151
|
start recording and load it before you run your tests)
|
152
152
|
|
153
|
-
* by generating a code coverage report
|
154
|
-
[simplecov](https://github.com/colszowka/simplecov) is a good one to start
|
153
|
+
* by generating a code coverage report
|
154
|
+
([simplecov](https://github.com/colszowka/simplecov) is a good one to start
|
155
155
|
with) from running this test in isolation, we can see what `LegacyWorker` is
|
156
156
|
actually calling, in an attempt to do two things:
|
157
157
|
* maximize coverage for code in the LegacyWorker (and for code that's
|
@@ -317,6 +317,7 @@ is a good choice).
|
|
317
317
|
Suture.config({
|
318
318
|
:log_level => "WARN", #<-- defaults to "INFO"
|
319
319
|
:log_stdout => false, #<-- defaults to true
|
320
|
+
:log_io => StringIO.new, #<-- defaults to nil
|
320
321
|
:log_file => "log/suture.log" #<-- defaults to nil
|
321
322
|
})
|
322
323
|
```
|
@@ -346,7 +347,7 @@ end
|
|
346
347
|
### Retrying failures
|
347
348
|
|
348
349
|
Since the legacy code path hasn't been deleted yet, there's no reason to leave
|
349
|
-
users hanging if the new code path explodes. By setting the `:
|
350
|
+
users hanging if the new code path explodes. By setting the `:fallback_on_error`
|
350
351
|
entry to `true`, Suture will rescue any errors raised from the new code path and
|
351
352
|
attempt to invoke the legacy code path instead.
|
352
353
|
|
@@ -357,7 +358,7 @@ class MyWorker
|
|
357
358
|
old: LegacyWorker.new,
|
358
359
|
new: NewWorker.new,
|
359
360
|
args: [id],
|
360
|
-
|
361
|
+
fallback_on_error: true
|
361
362
|
}))
|
362
363
|
end
|
363
364
|
end
|
@@ -368,6 +369,15 @@ path will go unnoticed, so it's best used in conjunction with Suture's logging
|
|
368
369
|
feature. Before ultimately deciding to finally delete the legacy code path,
|
369
370
|
double-check that the logs aren't full of rescued errors!
|
370
371
|
|
372
|
+
## Public API Summary
|
373
|
+
|
374
|
+
* `Suture.create(name, opts)` - Creates a seam in your production source code
|
375
|
+
* `Suture.verify(name, opts)` - Verifies a callable subject can recreate recorded calls
|
376
|
+
* `Suture.config(config)` - Sets logging options, as well global defaults for other properties
|
377
|
+
* `Suture.reset!` - Resets all Suture configuration
|
378
|
+
* `Suture.delete!(id)` - Deletes a recorded call by `id`
|
379
|
+
* `Suture.delete_all!(name)` - Deletes all recorded calls for a given seam `name`
|
380
|
+
|
371
381
|
## Configuration
|
372
382
|
|
373
383
|
Legacy code is, necessarily, complex and hard-to-wrangle. That's why Suture comes
|
@@ -479,4 +489,3 @@ seeing false negatives:
|
|
479
489
|
dropping Suture's database (which is, by default, stored in
|
480
490
|
`db/suture.sqlite3`) or by observing the ID of the recording from an error
|
481
491
|
message and invoking `Suture.delete!(42)`
|
482
|
-
|
data/Rakefile
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
2
|
require "rake/testtask"
|
3
3
|
|
4
|
-
Rake::TestTask.new(:
|
4
|
+
Rake::TestTask.new(:unit) do |t|
|
5
5
|
t.libs << "test"
|
6
6
|
t.libs << "lib"
|
7
7
|
t.test_files = FileList['test/helper.rb', 'test/**/*_test.rb']
|
@@ -13,7 +13,7 @@ Rake::TestTask.new(:safe) do |t|
|
|
13
13
|
t.test_files = FileList['safe/helper.rb', 'safe/**/*_test.rb']
|
14
14
|
end
|
15
15
|
|
16
|
-
Rake::TestTask.new(:
|
16
|
+
Rake::TestTask.new(:test) do |t|
|
17
17
|
t.libs << "test"
|
18
18
|
t.libs << "safe"
|
19
19
|
t.libs << "lib"
|
@@ -39,4 +39,4 @@ if Gem.ruby_version >= Gem::Version.new("2.2.2")
|
|
39
39
|
end
|
40
40
|
|
41
41
|
|
42
|
-
task :default => :
|
42
|
+
task :default => :test
|
@@ -1,7 +1,9 @@
|
|
1
1
|
require "suture/wrap/sqlite"
|
2
2
|
require "suture/adapter/log"
|
3
3
|
require "suture/value/observation"
|
4
|
+
require "suture/value/result"
|
4
5
|
require "suture/error/observation_conflict"
|
6
|
+
require "suture/util/compares_results"
|
5
7
|
|
6
8
|
module Suture::Adapter
|
7
9
|
class Dictaphone
|
@@ -10,24 +12,20 @@ module Suture::Adapter
|
|
10
12
|
def initialize(plan)
|
11
13
|
@db = Suture::Wrap::Sqlite.init(plan.database_path)
|
12
14
|
@name = plan.name
|
13
|
-
@
|
15
|
+
@compares_results = Suture::Util::ComparesResults.new(plan.comparator)
|
14
16
|
if plan.respond_to?(:args) # does not apply to TestPlan objects
|
15
17
|
@args_inspect = plan.args.inspect
|
16
18
|
@args_dump = Marshal.dump(plan.args)
|
17
19
|
end
|
18
20
|
end
|
19
21
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
log_debug("skipped recording of duplicate call for seam #{@name.inspect} with args `#{@args_inspect}` and result `#{result.inspect}`")
|
28
|
-
else
|
29
|
-
raise Suture::Error::ObservationConflict.new(@name, @args_inspect, result, old_observation)
|
30
|
-
end
|
22
|
+
|
23
|
+
def record(returned_value)
|
24
|
+
record_result(Suture::Value::Result.returned(returned_value))
|
25
|
+
end
|
26
|
+
|
27
|
+
def record_error(error)
|
28
|
+
record_result(Suture::Value::Result.errored(error))
|
31
29
|
end
|
32
30
|
|
33
31
|
def play(only_id = nil)
|
@@ -40,19 +38,42 @@ module Suture::Adapter
|
|
40
38
|
rows.map { |row| row_to_observation(row) }
|
41
39
|
end
|
42
40
|
|
43
|
-
def
|
41
|
+
def delete_by_id!(id)
|
44
42
|
log_info("deleting call with ID: #{id}")
|
45
43
|
Suture::Wrap::Sqlite.delete(@db, :observations, "where id = ?", [id])
|
46
44
|
end
|
47
45
|
|
46
|
+
def delete_by_name!(name)
|
47
|
+
log_info("deleting calls for seam named: #{name}")
|
48
|
+
Suture::Wrap::Sqlite.delete(@db, :observations, "where name = ?", [name.to_s])
|
49
|
+
end
|
50
|
+
|
48
51
|
private
|
49
52
|
|
53
|
+
def record_result(result)
|
54
|
+
Suture::Wrap::Sqlite.insert(
|
55
|
+
@db,
|
56
|
+
:observations,
|
57
|
+
[:name, :args, result.errored? ? :error : :result],
|
58
|
+
[@name.to_s, @args_dump, Marshal.dump(result.value)]
|
59
|
+
)
|
60
|
+
log_info("recorded call for seam #{@name.inspect} with args `#{@args_inspect}` and result `#{result.value.inspect}`")
|
61
|
+
rescue SQLite3::ConstraintException
|
62
|
+
old_observation = known_observation
|
63
|
+
if @compares_results.compare(old_observation.result, result)
|
64
|
+
log_debug("skipped recording of duplicate call for seam #{@name.inspect} with args `#{@args_inspect}` and result `#{result.value.inspect}`")
|
65
|
+
else
|
66
|
+
raise Suture::Error::ObservationConflict.new(@name, @args_inspect, result, old_observation)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
50
70
|
def row_to_observation(row)
|
51
71
|
Suture::Value::Observation.new(
|
52
|
-
row[0],
|
53
|
-
row[1].to_sym,
|
54
|
-
Marshal.load(row[2]),
|
55
|
-
Marshal.load(row[3])
|
72
|
+
:id => row[0],
|
73
|
+
:name => row[1].to_sym,
|
74
|
+
:args => Marshal.load(row[2]),
|
75
|
+
:return => row[3] ? Marshal.load(row[3]) : nil,
|
76
|
+
:error => row[4] ? Marshal.load(row[4]) : nil
|
56
77
|
)
|
57
78
|
end
|
58
79
|
|
data/lib/suture/adapter/log.rb
CHANGED
@@ -0,0 +1,13 @@
|
|
1
|
+
require "bar-of-progress"
|
2
|
+
|
3
|
+
module Suture::Adapter
|
4
|
+
class ProgressBar
|
5
|
+
def initialize(attrs = {})
|
6
|
+
@options = {:length => 60}.merge(attrs)
|
7
|
+
end
|
8
|
+
|
9
|
+
def progress(numerator, denominator)
|
10
|
+
BarOfProgress.new(@options.merge(:total => denominator)).progress(numerator)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/lib/suture/config.rb
CHANGED
@@ -35,13 +35,13 @@ module Suture
|
|
35
35
|
end
|
36
36
|
},
|
37
37
|
lambda { |plan|
|
38
|
-
if plan.record_calls && plan.
|
39
|
-
":record_calls & :
|
38
|
+
if plan.record_calls && plan.fallback_on_error
|
39
|
+
":record_calls & :fallback_on_error are both enabled and conflict with one another. :record_calls will only invoke the old code path (intended for characterization of the old code path and initial development of the new code path), whereas :fallback_on_error will call the new code path unless an error is raised, in which case it will fall back on the old code path."
|
40
40
|
end
|
41
41
|
},
|
42
42
|
lambda { |plan|
|
43
|
-
if plan.call_both && plan.
|
44
|
-
":call_both & :
|
43
|
+
if plan.call_both && plan.fallback_on_error
|
44
|
+
":call_both & :fallback_on_error are both enabled and conflict with one another. :call_both is designed for pre-production environments and will call both the old and new code paths to compare their results, whereas :fallback_on_error is designed for production environments where it is safe to call the old code path in the event that the new code path fails unexpectedly"
|
45
45
|
end
|
46
46
|
},
|
47
47
|
lambda { |plan|
|
@@ -50,8 +50,8 @@ module Suture
|
|
50
50
|
end
|
51
51
|
},
|
52
52
|
lambda { |plan|
|
53
|
-
if plan.
|
54
|
-
":
|
53
|
+
if plan.fallback_on_error && !plan.new.respond_to?(:call)
|
54
|
+
":fallback_on_error is set but :new is either not set or is not callable. This mode is designed for after the :new code path has been developed and run in production-like environments, where :old is only kept around as a fallback to retry in the event that :new raises an unexpected error. Either specify a :new code path or disable :fallback_on_error."
|
55
55
|
end
|
56
56
|
}
|
57
57
|
]
|
data/lib/suture/delete.rb
CHANGED
@@ -4,6 +4,11 @@ require "suture/adapter/dictaphone"
|
|
4
4
|
module Suture
|
5
5
|
def self.delete!(id, options = {})
|
6
6
|
plan = BuildsPlan.new.build(:name_not_used_here, options)
|
7
|
-
Adapter::Dictaphone.new(plan).
|
7
|
+
Adapter::Dictaphone.new(plan).delete_by_id!(id)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.delete_all!(name, options = {})
|
11
|
+
plan = BuildsPlan.new.build(:name_not_used_here, options)
|
12
|
+
Adapter::Dictaphone.new(plan).delete_by_name!(name)
|
8
13
|
end
|
9
14
|
end
|
@@ -17,10 +17,10 @@ module Suture::Error
|
|
17
17
|
#{@args_inspect}
|
18
18
|
```
|
19
19
|
Previously-observed return value: ```
|
20
|
-
#{@old_result.inspect}
|
20
|
+
#{@old_result.value.inspect}
|
21
21
|
```
|
22
22
|
Newly-observed return value: ```
|
23
|
-
#{@new_result.inspect}
|
23
|
+
#{@new_result.value.inspect}
|
24
24
|
```
|
25
25
|
|
26
26
|
That's not good! Here are a few ideas of what may have happened:
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require "suture/adapter/progress_bar"
|
2
|
+
|
1
3
|
module Suture::Error
|
2
4
|
class VerificationFailed < StandardError
|
3
5
|
def initialize(plan, results)
|
@@ -10,8 +12,10 @@ module Suture::Error
|
|
10
12
|
intro,
|
11
13
|
describe_failures(@results.failed, @plan),
|
12
14
|
configuration(@plan),
|
13
|
-
summarize(@results)
|
14
|
-
|
15
|
+
summarize(@results),
|
16
|
+
progress(@plan, @results)
|
17
|
+
].compact.join("\n").tap do
|
18
|
+
end
|
15
19
|
end
|
16
20
|
|
17
21
|
private
|
@@ -37,6 +41,23 @@ module Suture::Error
|
|
37
41
|
MSG
|
38
42
|
end
|
39
43
|
|
44
|
+
def progress(plan, results)
|
45
|
+
return if plan.fail_fast
|
46
|
+
bar = Suture::Adapter::ProgressBar.new.progress(
|
47
|
+
results.passed.size,
|
48
|
+
results.all.size
|
49
|
+
)
|
50
|
+
<<-MSG.gsub(/^ {8}/,'')
|
51
|
+
## Progress
|
52
|
+
|
53
|
+
Here's what your progress to initial completion looks like so far:
|
54
|
+
|
55
|
+
#{bar}
|
56
|
+
|
57
|
+
Of #{results.all.size} recorded interactions, #{results.passed.size} are currently passing.
|
58
|
+
MSG
|
59
|
+
end
|
60
|
+
|
40
61
|
def configuration(plan)
|
41
62
|
<<-MSG.gsub(/^ {8}/,'')
|
42
63
|
# Configuration
|
@@ -88,24 +109,24 @@ module Suture::Error
|
|
88
109
|
return <<-MSG.gsub(/^ {8}/,'')
|
89
110
|
#{index + 1}.) Recorded call for seam #{expected.name.inspect} (ID: #{expected.id}) ran and #{failure[:error] ? "raised an error" : "failed comparison"}.
|
90
111
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
112
|
+
Arguments: ```
|
113
|
+
#{expected.args.inspect}
|
114
|
+
```
|
115
|
+
Expected #{expected.result.errored? ? "error raised" : "returned value"}: ```
|
116
|
+
#{expected.result.value.inspect}
|
117
|
+
```
|
118
|
+
Actual #{(failure[:error] || failure[:new_result].errored?) ? "error raised" : "returned value"}: ```
|
119
|
+
#{if failure[:error]
|
120
|
+
stringify_error(failure[:error])
|
121
|
+
else
|
122
|
+
failure[:new_result].value.inspect
|
123
|
+
end
|
124
|
+
}
|
125
|
+
```
|
126
|
+
|
127
|
+
Ideas to fix this:
|
128
|
+
* Focus on this test by setting ENV var `SUTURE_VERIFY_ONLY=#{expected.id}`
|
129
|
+
* Is the recording wrong? Delete it! `Suture.delete!(#{expected.id})`
|
109
130
|
MSG
|
110
131
|
end
|
111
132
|
|
data/lib/suture/surgeon/no_op.rb
CHANGED
@@ -3,8 +3,19 @@ require "suture/util/scalpel"
|
|
3
3
|
module Suture::Surgeon
|
4
4
|
class NoOp
|
5
5
|
def operate(plan)
|
6
|
-
|
7
|
-
|
6
|
+
if code_path = code_path_for(plan)
|
7
|
+
Suture::Util::Scalpel.new.cut(plan, code_path)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def code_path_for(plan)
|
14
|
+
if plan.new && !plan.disable
|
15
|
+
:new
|
16
|
+
elsif plan.old
|
17
|
+
:old
|
18
|
+
end
|
8
19
|
end
|
9
20
|
end
|
10
21
|
end
|
@@ -5,8 +5,15 @@ module Suture::Surgeon
|
|
5
5
|
class Observer
|
6
6
|
def operate(plan)
|
7
7
|
dictaphone = Suture::Adapter::Dictaphone.new(plan)
|
8
|
-
|
9
|
-
|
8
|
+
begin
|
9
|
+
Suture::Util::Scalpel.new.cut(plan, :old).tap do |result|
|
10
|
+
dictaphone.record(result)
|
11
|
+
end
|
12
|
+
rescue StandardError => error
|
13
|
+
if plan.expected_error_types.any? {|e| error.kind_of?(e) }
|
14
|
+
dictaphone.record_error(error)
|
15
|
+
end
|
16
|
+
raise error
|
10
17
|
end
|
11
18
|
end
|
12
19
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Suture::Util
|
2
|
+
class ComparesResults
|
3
|
+
def initialize(comparator)
|
4
|
+
@comparator = comparator
|
5
|
+
end
|
6
|
+
|
7
|
+
def compare(expected, actual)
|
8
|
+
if expected.errored? != actual.errored?
|
9
|
+
false
|
10
|
+
elsif expected.errored?
|
11
|
+
actual.value.kind_of?(expected.value.class) &&
|
12
|
+
expected.value.message == actual.value.message
|
13
|
+
else
|
14
|
+
@comparator.call(expected.value, actual.value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/suture/util/scalpel.rb
CHANGED
@@ -1,12 +1,17 @@
|
|
1
|
+
require "suture/adapter/log"
|
2
|
+
|
1
3
|
module Suture::Util
|
2
4
|
class Scalpel
|
5
|
+
include Suture::Adapter::Log
|
6
|
+
|
3
7
|
def cut(plan, location, args_override = nil)
|
4
|
-
args =
|
8
|
+
args = args_for(plan, args_override)
|
5
9
|
begin
|
6
10
|
plan.send(location).call(*args).tap do |result|
|
7
11
|
call_after_hook(plan, location, args, result)
|
8
12
|
end
|
9
13
|
rescue StandardError => error
|
14
|
+
log_error_details(plan, location, args, error)
|
10
15
|
call_error_hook(plan, location, args, error)
|
11
16
|
raise error
|
12
17
|
end
|
@@ -16,16 +21,37 @@ module Suture::Util
|
|
16
21
|
|
17
22
|
def call_after_hook(plan, location, args, result)
|
18
23
|
return unless after_hook = try(plan, "after_#{location}")
|
19
|
-
after_hook.call(
|
24
|
+
after_hook.call(args, result)
|
25
|
+
end
|
26
|
+
|
27
|
+
def log_error_details(plan, location, args, error)
|
28
|
+
return if expected_error?(plan, error)
|
29
|
+
log_error <<-MSG.gsub(/^ {8}/,'')
|
30
|
+
Suture invoked the #{plan.name.inspect} seam's #{location.inspect} code path with args: ```
|
31
|
+
#{args.inspect}
|
32
|
+
```
|
33
|
+
which raised a #{error.class} with message: ```
|
34
|
+
#{error.message}
|
35
|
+
```
|
36
|
+
MSG
|
20
37
|
end
|
21
38
|
|
22
39
|
def call_error_hook(plan, location, args, error)
|
23
|
-
return if plan
|
40
|
+
return if expected_error?(plan, error)
|
24
41
|
return unless error_hook = try(plan, "on_#{location}_error")
|
25
42
|
error_hook.call(plan.name, args, error)
|
26
43
|
end
|
27
44
|
|
28
|
-
def
|
45
|
+
def expected_error?(plan, error)
|
46
|
+
plan.expected_error_types.any? {|e| error.kind_of?(e) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def args_for(plan, args_override)
|
50
|
+
args = pick_args(plan, args_override)
|
51
|
+
try(plan, :dup_args) ? Marshal.load(Marshal.dump(args)) : args
|
52
|
+
end
|
53
|
+
|
54
|
+
def pick_args(plan, args_override)
|
29
55
|
return args_override if args_override
|
30
56
|
if plan.respond_to?(:args)
|
31
57
|
plan.args
|
@@ -1,11 +1,19 @@
|
|
1
|
+
require "suture/value/result"
|
2
|
+
|
1
3
|
module Suture::Value
|
2
4
|
class Observation
|
3
|
-
attr_reader :id, :name, :args
|
4
|
-
|
5
|
-
|
6
|
-
@
|
7
|
-
@
|
8
|
-
@
|
5
|
+
attr_reader :id, :name, :args
|
6
|
+
|
7
|
+
def initialize(attrs)
|
8
|
+
@id = attrs[:id]
|
9
|
+
@name = attrs[:name]
|
10
|
+
@args = attrs[:args]
|
11
|
+
@return_value = attrs[:return]
|
12
|
+
@error = attrs[:error]
|
13
|
+
end
|
14
|
+
|
15
|
+
def result
|
16
|
+
@expectation ||= @error ? Result.errored(@error) : Result.returned(@return_value)
|
9
17
|
end
|
10
18
|
end
|
11
19
|
end
|
data/lib/suture/value/plan.rb
CHANGED
@@ -2,8 +2,8 @@ module Suture::Value
|
|
2
2
|
class Plan
|
3
3
|
attr_reader :name, :old, :new, :args, :after_new, :after_old, :on_new_error,
|
4
4
|
:on_old_error, :database_path, :record_calls, :comparator,
|
5
|
-
:call_both, :raise_on_result_mismatch, :
|
6
|
-
:expected_error_types, :disable
|
5
|
+
:call_both, :raise_on_result_mismatch, :fallback_on_error,
|
6
|
+
:expected_error_types, :disable, :dup_args
|
7
7
|
|
8
8
|
def initialize(attrs = {})
|
9
9
|
@name = attrs[:name]
|
@@ -19,9 +19,10 @@ module Suture::Value
|
|
19
19
|
@comparator = attrs[:comparator]
|
20
20
|
@call_both = !!attrs[:call_both]
|
21
21
|
@raise_on_result_mismatch = !!attrs[:raise_on_result_mismatch]
|
22
|
-
@
|
22
|
+
@fallback_on_error = !!attrs[:fallback_on_error]
|
23
23
|
@expected_error_types = attrs[:expected_error_types] || []
|
24
24
|
@disable = !!attrs[:disable]
|
25
|
+
@dup_args = !!attrs[:dup_args]
|
25
26
|
end
|
26
27
|
end
|
27
28
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Suture::Value
|
2
|
+
class Result
|
3
|
+
def self.errored(error)
|
4
|
+
new(:error, error)
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.returned(return_value)
|
8
|
+
new(:return_value, return_value)
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :value
|
12
|
+
def initialize(result_type, value)
|
13
|
+
@value = value
|
14
|
+
@errored = result_type == :error
|
15
|
+
end
|
16
|
+
|
17
|
+
def errored?
|
18
|
+
@errored
|
19
|
+
end
|
20
|
+
|
21
|
+
def ==(other)
|
22
|
+
other.kind_of?(self.class) && other.state == state
|
23
|
+
end
|
24
|
+
|
25
|
+
def hash
|
26
|
+
state.hash
|
27
|
+
end
|
28
|
+
|
29
|
+
protected
|
30
|
+
|
31
|
+
def state
|
32
|
+
[@value, @errored]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -7,11 +7,12 @@ module Suture::Value
|
|
7
7
|
:expected_error_types
|
8
8
|
|
9
9
|
def initialize(attrs = {})
|
10
|
-
assign_simple_ivars!(attrs, :name, :subject, :
|
10
|
+
assign_simple_ivars!(attrs, :name, :subject, :comparator,
|
11
11
|
:database_path, :after_subject,
|
12
12
|
:on_subject_error)
|
13
13
|
assign_integral_ivars!(attrs, :verify_only, :call_limit, :time_limit,
|
14
14
|
:error_message_limit)
|
15
|
+
@fail_fast = !!attrs[:fail_fast]
|
15
16
|
@expected_error_types = attrs[:expected_error_types] || []
|
16
17
|
@random_seed = determine_random_seed(attrs)
|
17
18
|
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
module Suture::Value
|
2
2
|
class TestResults
|
3
|
-
attr_reader :results
|
4
|
-
|
5
3
|
def initialize(results)
|
6
4
|
@results = results
|
7
5
|
end
|
@@ -22,6 +20,14 @@ module Suture::Value
|
|
22
20
|
@results.count { |r| r[:passed] }
|
23
21
|
end
|
24
22
|
|
23
|
+
def all
|
24
|
+
@results
|
25
|
+
end
|
26
|
+
|
27
|
+
def passed
|
28
|
+
@results.select { |r| r[:passed] }
|
29
|
+
end
|
30
|
+
|
25
31
|
def failed
|
26
32
|
@results.select { |r| !r[:passed] && r[:ran] }
|
27
33
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require "suture/util/compares_results"
|
2
|
+
require "suture/util/scalpel"
|
3
|
+
require "suture/value/result"
|
4
|
+
|
5
|
+
module Suture
|
6
|
+
class AdministersTest
|
7
|
+
def initialize
|
8
|
+
@scalpel = Util::Scalpel.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def administer(test_plan, observation)
|
12
|
+
compares_results = Util::ComparesResults.new(test_plan.comparator)
|
13
|
+
begin
|
14
|
+
result = Value::Result.returned(@scalpel.cut(test_plan, :subject, observation.args))
|
15
|
+
{
|
16
|
+
:new_result => result,
|
17
|
+
:passed => compares_results.compare(observation.result, result)
|
18
|
+
}
|
19
|
+
rescue StandardError => error
|
20
|
+
if observation.result.errored?
|
21
|
+
result = Value::Result.errored(error)
|
22
|
+
{
|
23
|
+
:new_result => result,
|
24
|
+
:passed => compares_results.compare(observation.result, result)
|
25
|
+
}
|
26
|
+
else
|
27
|
+
{ :error => error, :passed => false }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -1,14 +1,17 @@
|
|
1
|
+
require "suture/verify/administers_test"
|
2
|
+
require "suture/adapter/log"
|
1
3
|
require "suture/adapter/dictaphone"
|
2
4
|
require "suture/value/test_results"
|
3
|
-
require "suture/util/scalpel"
|
4
5
|
require "suture/util/shuffle"
|
5
6
|
require "suture/util/timer"
|
6
7
|
require "backports/1.9.2/random"
|
7
8
|
|
8
9
|
module Suture
|
9
10
|
class TestsPatient
|
11
|
+
include Suture::Adapter::Log
|
12
|
+
|
10
13
|
def initialize
|
11
|
-
@
|
14
|
+
@administers_test = AdministersTest.new
|
12
15
|
end
|
13
16
|
|
14
17
|
def test(test_plan)
|
@@ -16,15 +19,13 @@ module Suture
|
|
16
19
|
timer = Suture::Util::Timer.new(test_plan.time_limit) unless test_plan.time_limit.nil?
|
17
20
|
test_cases = build_test_cases(test_plan)
|
18
21
|
Value::TestResults.new(test_cases.each_with_index.map { |observation, i|
|
19
|
-
if (test_plan
|
20
|
-
(test_plan.call_limit && i >= test_plan.call_limit) ||
|
21
|
-
(timer && timer.time_up?)
|
22
|
+
if should_skip?(test_plan, experienced_failure_in_life, i, timer)
|
22
23
|
{
|
23
24
|
:observation => observation,
|
24
25
|
:ran => false
|
25
26
|
}
|
26
27
|
else
|
27
|
-
|
28
|
+
@administers_test.administer(test_plan, observation).merge({
|
28
29
|
:observation => observation,
|
29
30
|
:ran => true
|
30
31
|
}).tap { |r| experienced_failure_in_life = true unless r[:passed] }
|
@@ -34,30 +35,31 @@ module Suture
|
|
34
35
|
|
35
36
|
private
|
36
37
|
|
38
|
+
def should_skip?(test_plan, failed_fast, call_count, timer)
|
39
|
+
(test_plan.fail_fast && failed_fast) ||
|
40
|
+
(test_plan.call_limit && call_count >= test_plan.call_limit) ||
|
41
|
+
(timer && timer.time_up?)
|
42
|
+
end
|
43
|
+
|
37
44
|
def build_test_cases(test_plan)
|
38
45
|
dictaphone = Suture::Adapter::Dictaphone.new(test_plan)
|
39
46
|
shuffle(
|
40
47
|
dictaphone.play(test_plan.verify_only),
|
41
48
|
test_plan.random_seed
|
42
|
-
)
|
49
|
+
).tap do |test_cases|
|
50
|
+
next if test_cases.size > 0
|
51
|
+
log_warn <<-MSG.gsub(/^ {10}/,'')
|
52
|
+
Suture.verify found no recorded calls for seam #{test_plan.name.inspect}.
|
53
|
+
As a result, verify will have no effect and cannot provide any assurance
|
54
|
+
that the subject is working as expected.
|
55
|
+
MSG
|
56
|
+
end
|
43
57
|
end
|
44
58
|
|
45
59
|
def shuffle(rows, random_seed)
|
46
60
|
return rows unless random_seed
|
47
61
|
Suture::Util::Shuffle.shuffle(Random.new(random_seed), rows)
|
48
62
|
end
|
49
|
-
|
50
|
-
def invoke(test_plan, observation)
|
51
|
-
{}.tap do |result|
|
52
|
-
begin
|
53
|
-
result[:new_result] = @scalpel.cut(test_plan, :subject, observation.args)
|
54
|
-
result[:passed] = test_plan.comparator.call(observation.result, result[:new_result])
|
55
|
-
rescue StandardError => e
|
56
|
-
result[:passed] = false
|
57
|
-
result[:error] = e
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
63
|
end
|
62
64
|
end
|
63
65
|
|
data/lib/suture/version.rb
CHANGED
data/lib/suture/wrap/logger.rb
CHANGED
@@ -3,14 +3,12 @@ require "logger"
|
|
3
3
|
module Suture::Wrap
|
4
4
|
module Logger
|
5
5
|
def self.init(options)
|
6
|
-
if options[:log_file]
|
6
|
+
@logger = if options[:log_file]
|
7
7
|
full_path = File.join(Dir.getwd, options[:log_file])
|
8
8
|
FileUtils.mkdir_p(File.dirname(full_path))
|
9
|
-
|
10
|
-
elsif options[:log_stdout]
|
11
|
-
@logger = ::Logger.new(STDOUT)
|
9
|
+
::Logger.new(full_path)
|
12
10
|
else
|
13
|
-
|
11
|
+
::Logger.new(NullIO.new)
|
14
12
|
end
|
15
13
|
|
16
14
|
@logger.level = if options[:log_level]
|
@@ -22,19 +20,24 @@ module Suture::Wrap
|
|
22
20
|
@logger.formatter = proc { |_, time , _, msg|
|
23
21
|
formatted_time = time.strftime("%Y-%m-%dT%H:%M:%S.%6N")
|
24
22
|
"[#{formatted_time}] Suture: #{msg}\n".tap { |out|
|
25
|
-
puts out if options[:
|
23
|
+
puts out if options[:log_stdout]
|
24
|
+
options[:log_io].write(out) if options[:log_io]
|
26
25
|
}
|
27
26
|
}
|
28
27
|
|
29
28
|
return @logger
|
30
29
|
end
|
31
30
|
|
32
|
-
class
|
33
|
-
def
|
34
|
-
def
|
35
|
-
def
|
36
|
-
def
|
37
|
-
def
|
31
|
+
class NullIO
|
32
|
+
def gets; end
|
33
|
+
def each; end
|
34
|
+
def read(count=nil,buffer=nil); (count && count > 0) ? nil : ""; end
|
35
|
+
def rewind; 0; end
|
36
|
+
def close; end
|
37
|
+
def size; 0; end
|
38
|
+
def sync=(*args); end
|
39
|
+
def puts(*args); end
|
40
|
+
def write(*args); end
|
38
41
|
end
|
39
42
|
end
|
40
43
|
end
|
data/lib/suture/wrap/sqlite.rb
CHANGED
@@ -4,18 +4,18 @@ require "suture/error/schema_version"
|
|
4
4
|
|
5
5
|
module Suture::Wrap
|
6
6
|
module Sqlite
|
7
|
-
SCHEMA_VERSION=
|
7
|
+
SCHEMA_VERSION=2
|
8
8
|
def self.init(location)
|
9
9
|
full_path = File.join(Dir.getwd, location)
|
10
10
|
FileUtils.mkdir_p(File.dirname(full_path))
|
11
11
|
SQLite3::Database.new(full_path).tap do |db|
|
12
12
|
db.execute <<-SQL
|
13
|
-
create table if not exists
|
13
|
+
create table if not exists suture_schema_info (
|
14
14
|
version integer unique
|
15
15
|
);
|
16
16
|
SQL
|
17
|
-
db.execute("insert or ignore into
|
18
|
-
actual_schema_version = db.execute("select * from
|
17
|
+
db.execute("insert or ignore into suture_schema_info values (?)", [SCHEMA_VERSION])
|
18
|
+
actual_schema_version = db.execute("select * from suture_schema_info").first[0]
|
19
19
|
if SCHEMA_VERSION != actual_schema_version
|
20
20
|
raise Suture::Error::SchemaVersion.new(SCHEMA_VERSION, actual_schema_version)
|
21
21
|
end
|
@@ -23,9 +23,10 @@ module Suture::Wrap
|
|
23
23
|
db.execute <<-SQL
|
24
24
|
create table if not exists observations (
|
25
25
|
id integer primary key,
|
26
|
-
name varchar(255),
|
27
|
-
args clob,
|
26
|
+
name varchar(255) not null,
|
27
|
+
args clob not null,
|
28
28
|
result clob,
|
29
|
+
error clob,
|
29
30
|
unique(name, args)
|
30
31
|
);
|
31
32
|
SQL
|
data/suture.gemspec
CHANGED
@@ -13,13 +13,14 @@ Gem::Specification.new do |spec|
|
|
13
13
|
spec.description = %q{Provides tools to record calls to legacy code and verify new implementations still work}
|
14
14
|
spec.homepage = "https://github.com/testdouble/suture"
|
15
15
|
|
16
|
-
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|db|log|safe|spec|features)/}) }
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(example|test|db|log|safe|spec|features)/}) }
|
17
17
|
spec.bindir = "exe"
|
18
18
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
21
|
spec.add_dependency "sqlite3"
|
22
22
|
spec.add_dependency "backports"
|
23
|
+
spec.add_dependency "bar-of-progress", ">= 0.1.3"
|
23
24
|
|
24
25
|
spec.add_development_dependency "bundler", "~> 1.12"
|
25
26
|
spec.add_development_dependency "rake", "~> 10.0"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: suture
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Justin Searls
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-09-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sqlite3
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bar-of-progress
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.1.3
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.1.3
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: bundler
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -173,6 +187,7 @@ files:
|
|
173
187
|
- lib/suture.rb
|
174
188
|
- lib/suture/adapter/dictaphone.rb
|
175
189
|
- lib/suture/adapter/log.rb
|
190
|
+
- lib/suture/adapter/progress_bar.rb
|
176
191
|
- lib/suture/comparator.rb
|
177
192
|
- lib/suture/config.rb
|
178
193
|
- lib/suture/create.rb
|
@@ -191,15 +206,18 @@ files:
|
|
191
206
|
- lib/suture/surgeon/no_op.rb
|
192
207
|
- lib/suture/surgeon/observer.rb
|
193
208
|
- lib/suture/surgeon/remediator.rb
|
209
|
+
- lib/suture/util/compares_results.rb
|
194
210
|
- lib/suture/util/env.rb
|
195
211
|
- lib/suture/util/scalpel.rb
|
196
212
|
- lib/suture/util/shuffle.rb
|
197
213
|
- lib/suture/util/timer.rb
|
198
214
|
- lib/suture/value/observation.rb
|
199
215
|
- lib/suture/value/plan.rb
|
216
|
+
- lib/suture/value/result.rb
|
200
217
|
- lib/suture/value/test_plan.rb
|
201
218
|
- lib/suture/value/test_results.rb
|
202
219
|
- lib/suture/verify.rb
|
220
|
+
- lib/suture/verify/administers_test.rb
|
203
221
|
- lib/suture/verify/interprets_results.rb
|
204
222
|
- lib/suture/verify/prescribes_test_plan.rb
|
205
223
|
- lib/suture/verify/tests_patient.rb
|