spinel_test-unit 0.1.2 → 0.3.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: a36d406fd907591eb344d09238539b48c5c284a629847fca832dd696114b4cd4
4
- data.tar.gz: d982c5bafe885e787e93f60b4434b75f295f54df415bb73aa35e25123a5a6150
3
+ metadata.gz: ef62e948140f04c676b0709a5780239556c38dd521fbdd0fce4968f82b643fe4
4
+ data.tar.gz: 3212cc091c366bc33b3fc1854f3fbd6a4a164248baa20cdc3816d3896dcd8519
5
5
  SHA512:
6
- metadata.gz: 6f17eda9a7f16b435bb057fc6718ae3930ba5dd2c492fbafa60c840e7918d09db300851b57917d8d027a1d53b8b06f807b53ecd9bf595a82981494e3b1521739
7
- data.tar.gz: c9b3ff1bea0017c169a937121008e2d7527da44b852bde7ca284eeb14d85114bf84d42a6f5262cfc5e62138f8db7f52607d9b7177b0e79378be17451e807af01
6
+ metadata.gz: 9dcc12b871aa16d10cea18c038de5d335a4e07121098f3de6b3449be0020e82274d39593698b79a39457dd9049ccdb1fcc06c740458d536151dd592ba93b66e9
7
+ data.tar.gz: 588963b23dc23425e40721efad1070800e577aec65d9dbba959aa537f3055ddb0a06db18df1671ae484733d5d7055f2f0da8e102213cbc2598897a618be0e43b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 0.3.0
4
+
5
+ * Add `match:` to `assert_raise` to check the exception message, as in `assert_match` (a Regexp, or a String meaning a substring)
6
+ * `assert_raise` passes only for the given class itself, not a subclass, as in test-unit. Use the new `assert_raise_kind_of` to also accept a subclass
7
+ * Add `-t`/`--testcase CLASS` to run only the tests of the class named exactly CLASS (`-n FooTest#` also selects `MyFooTest`)
8
+ * `-n` and `-t` take a regexp written as `/regexp/`, as in test-unit
9
+
10
+ ## 0.2.0
11
+
12
+ * Report where a test failed: `at file:line` lines after the message (the test method's line with Spinel, the filtered backtrace with `--ruby`)
13
+ * Show a line diff when `assert_equal` fails on two multi-line Strings
14
+ * Add `assert_match`, `assert_not_equal`, `assert_not_nil`, `assert_include`, `assert_not_include` and `refute`. `assert_includes` remains as an alias of `assert_include`
15
+ * Add `--ruby` to run the tests with the current Ruby instead of compiling them with Spinel
16
+
3
17
  ## 0.1.2
4
18
 
5
19
  * Exclude mise.toml from the gem
data/README.md CHANGED
@@ -39,26 +39,65 @@ end
39
39
  $ bundle exec spinel_test-unit # test/**/*_test.rb and test/**/test_*.rb
40
40
  $ bundle exec spinel_test-unit test/calc_test.rb
41
41
  $ bundle exec spinel_test-unit -n test_add # only tests whose "Class#method" name includes it
42
+ $ bundle exec spinel_test-unit -n '/#test_add\z/' # or matches the regexp
43
+ $ bundle exec spinel_test-unit -t CalcTest # only the tests of the class named exactly CalcTest
44
+ $ bundle exec spinel_test-unit -t '/\ACalc/' # or of the classes whose name matches the regexp
45
+ $ bundle exec spinel_test-unit --ruby # run the same tests with the current Ruby, no Spinel needed
42
46
  ```
43
47
 
44
- Output:
48
+ `--ruby` runs the generated main.rb with the Ruby that runs the CLI, with the same load paths (`-I`, `lib`, `test`, the project root) instead of compiling it; the child also inherits `RUBYOPT` and `RUBYLIB` (e.g. `bundler/setup`), so it may resolve requires that Spinel cannot. Use it to check that code which must work under both CRuby and Spinel behaves the same in each. The same tests run, with the same report format and exit status convention, but results can differ where CRuby and Spinel differ: Spinel freezes string literals (so `s = "a"; s << "b"` raises under Spinel but passes under `--ruby`), and exception messages can be worded differently.
49
+
50
+ Output when compiled with Spinel:
45
51
 
46
52
  ```
47
- ..F
53
+ .F
48
54
 
49
55
  Failure: CalcTest#test_failure_message
50
56
  1 + 1 should be 2
57
+ at test/calc_test.rb:14
58
+
59
+ 2 tests, 2 assertions, 1 failures, 0 errors
60
+ ```
61
+
62
+ Each failure or error is `Failure: Class#test_name` or `Error: Class#test_name`, the message, and up to three ` at file:line` lines (paths relative to the project root):
63
+
64
+ - Compiled with Spinel, exceptions have no backtrace, so there is one line: the `file:line` of the test method's `def`.
65
+ - With `--ruby`, the lines are the backtrace entries outside this gem and the generated main.rb, so they point at the failing assertion in the test and the callers above it (a test helper, for example). The first entry is always kept, even from outside the project root (an exception raised inside another library shows where); after it only entries under the root are listed. A location equal to the previous line is left out (Ruby 4.0 attributes block-taking builtins such as `Array#each` to their caller's line). If nothing is left after filtering, it falls back to the test method's line.
66
+ - An error raised in `setup`, or in `teardown` when the test itself passed, is reported with the same lines (under Spinel, the test method's `def`).
67
+ - If a test method is defined more than once, the location of the last definition is used.
68
+
69
+ When `assert_equal` fails on two Strings that both contain a newline, a line diff follows the Expected/Actual lines. `-` lines are only in the expected string, `+` lines only in the actual one, and unchanged lines more than two away from a change are collapsed into `...`. A single hidden line is shown as is. Lines that could be misread (empty, containing `\r`, starting with `"`, equal to `...`, or starting or ending with a space or tab) are shown with `inspect` (`- ""`, `- "a\r"`). Example:
51
70
 
52
- 3 tests, 3 assertions, 1 failures, 0 errors
53
71
  ```
72
+ Failure: HttpTest#test_to_cgi
73
+ Expected: "a\nb\nc\nd\ne\nf"
74
+ Actual: "a\nb\nX\nd\ne\nf"
75
+ Diff:
76
+ a
77
+ b
78
+ - c
79
+ + X
80
+ d
81
+ e
82
+ f
83
+ at test/http_test.rb:7
84
+ ```
85
+
86
+ Very large differences are not diffed: when the lines between the common start and end of the two strings would need a table of over a million cells, the diff is replaced by a one-line note and the Expected/Actual lines remain.
54
87
 
55
88
  Available assertions:
56
89
 
57
90
  - `assert(test, message = nil)`
58
91
  - `assert_equal(expected, actual, message = nil)`
92
+ - `assert_not_equal(expected, actual, message = nil)`
59
93
  - `assert_nil(value, message = nil)`
60
- - `assert_includes(collection, item, message = nil)`
61
- - `assert_raise(klass) { ... }` (passes for `klass` or a subclass; returns the exception; assertion failures raised inside the block propagate)
94
+ - `assert_not_nil(value, message = nil)`
95
+ - `assert_match(pattern, string, message = nil)` (`pattern` is a Regexp, or a String meaning a substring, and anything else is reported as an error; a `string` that is not a String, such as `nil`, fails)
96
+ - `assert_include(collection, item, message = nil)` (`assert_includes` is an alias)
97
+ - `assert_not_include(collection, item, message = nil)`
98
+ - `refute(test, message = nil)`
99
+ - `assert_raise(klass, match: nil) { ... }` (passes for `klass` itself, not a subclass, as in test-unit; returns the exception; assertion failures raised inside the block propagate). With `match:`, the exception's message must also match it, as in `assert_match` (a Regexp, or a String meaning a substring)
100
+ - `assert_raise_kind_of(klass, match: nil) { ... }` (as `assert_raise`, but also passes for a subclass of `klass`)
62
101
 
63
102
  `setup` and `teardown` hooks are supported.
64
103
 
@@ -83,6 +122,7 @@ The tests are compiled for build speed, not run speed: with `-O0`, and the gener
83
122
  - Files are loaded with `require` and the load paths `lib`, `test` and the
84
123
  project root; use `require "..."` / `require_relative` that Spinel resolves.
85
124
  - Test files under `fixtures/` directories (relative to the project root) are skipped unless named explicitly or the given directory is itself inside `fixtures/`.
125
+ - A method parameter is typed from all of its call sites, so mixing a nil-typed local and, say, an Integer in calls to the same assertion can fail to compile or raise at run time under Spinel. For example, `refute 0` together with `n = nil; refute n` in the test files compiled together fails at run time ("nil can't be coerced into Integer") while `--ruby` passes. Keep the arguments of one assertion to one type across those files.
86
126
  - `ScriptError`s (such as `NotImplementedError`) raised by a test are reported as errors; a teardown error after a failure is appended to its message.
87
127
 
88
128
  ## Development
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "open3"
5
+ require "rbconfig"
5
6
  require "tmpdir"
6
7
  require_relative "scanner"
7
8
  require_relative "generator"
@@ -9,10 +10,12 @@ require_relative "toolchain"
9
10
 
10
11
  module SpinelTest
11
12
  module Unit
12
- # spinel_test-unit [PATH...] [-n NAME]
13
+ # spinel_test-unit [PATH...] [-n NAME] [-t CLASS] [-I DIR] [--ruby]
13
14
  #
14
15
  # Scans the test files with Prism, generates a main.rb that calls every
15
- # test, compiles it with Spinel and runs the binary.
16
+ # test, compiles it with Spinel and runs the binary. With --ruby it runs the
17
+ # generated main.rb with the current Ruby instead, so one test file can be
18
+ # checked under both.
16
19
  class CLI
17
20
  def self.start(argv, root: Dir.pwd)
18
21
  new(argv, root: root).run
@@ -22,20 +25,27 @@ module SpinelTest
22
25
  @root = File.expand_path(root)
23
26
  @paths = []
24
27
  @filter = nil
25
- @keep = false
28
+ @testcase = nil
29
+ @ruby = false
26
30
  @includes = []
27
31
  @exit = nil
28
32
  parse(argv.dup)
29
33
  end
30
34
 
31
35
  USAGE = <<~TEXT
32
- Usage: spinel_test-unit [PATH...] [-n NAME] [-I DIR]
36
+ Usage: spinel_test-unit [PATH...] [-n NAME] [-t CLASS] [-I DIR] [--ruby]
33
37
 
34
- Compiles the tests with Spinel and runs them.
38
+ Compiles the tests with Spinel and runs them (with --ruby, runs them with the
39
+ current Ruby instead).
35
40
 
36
41
  PATH test file or directory (default: test)
37
- -n, --name NAME run only the tests whose "Class#test_name" includes NAME
38
- -I DIR add DIR to Spinel's load path (repeatable)
42
+ -n, --name NAME run only the tests whose "Class#test_name" includes NAME,
43
+ or matches it when NAME is /regexp/
44
+ -t, --testcase CLASS
45
+ run only the tests of the class named CLASS (exact match),
46
+ or whose class name matches it when CLASS is /regexp/
47
+ -I DIR add DIR to the load path (repeatable)
48
+ --ruby run with the current Ruby instead of compiling with Spinel
39
49
  -h, --help show this help
40
50
  TEXT
41
51
 
@@ -48,10 +58,9 @@ module SpinelTest
48
58
  return 1
49
59
  end
50
60
 
51
- spinel = Toolchain.spinel
52
- unless spinel
53
- warn "spinel_test-unit: Spinel not found (set SPINEL, put spinel on PATH, or install it with mise)"
54
- return 1
61
+ unless @ruby
62
+ spinel = find_spinel
63
+ return 1 unless spinel
55
64
  end
56
65
 
57
66
  entries = build_entries(files)
@@ -62,17 +71,38 @@ module SpinelTest
62
71
 
63
72
  Dir.mktmpdir("spinel_test-unit") do |dir|
64
73
  main = File.join(dir, "main.rb")
65
- bin = File.join(dir, "testbin")
66
- File.write(main, Generator.generate(entries))
67
- return 1 unless compile(spinel, main, bin, entries)
68
-
69
- args = @filter ? ["-n", @filter] : []
70
- system(bin, *args) ? 0 : ($?.exitstatus || 1)
74
+ File.write(main, Generator.generate(entries, @root))
75
+ @ruby ? run_with_ruby(main, entries) : run_with_spinel(spinel, main, dir, entries)
71
76
  end
72
77
  end
73
78
 
74
79
  private
75
80
 
81
+ def find_spinel
82
+ spinel = Toolchain.spinel
83
+ warn "spinel_test-unit: Spinel not found (set SPINEL, put spinel on PATH, or install it with mise)" if spinel.nil?
84
+ spinel
85
+ end
86
+
87
+ def run_with_spinel(spinel, main, dir, entries)
88
+ bin = File.join(dir, "testbin")
89
+ return 1 unless compile(spinel, main, bin, entries)
90
+
91
+ run_command([bin])
92
+ end
93
+
94
+ def run_with_ruby(main, entries)
95
+ load_path = include_dirs(entries).flat_map { |dir| ["-I", dir] }
96
+ run_command([RbConfig.ruby] + load_path + [main])
97
+ end
98
+
99
+ def run_command(command)
100
+ args = []
101
+ args += ["-n", @filter] if @filter
102
+ args += ["-t", @testcase] if @testcase
103
+ system(*command, *args) ? 0 : ($?.exitstatus || 1)
104
+ end
105
+
76
106
  def parse(args)
77
107
  until args.empty?
78
108
  arg = args.shift
@@ -82,16 +112,27 @@ module SpinelTest
82
112
  @exit = 0
83
113
  return
84
114
  when "-n", "--name"
85
- if args.empty?
86
- return usage_error("#{arg} requires a value")
87
- end
115
+ return usage_error("#{arg} requires a value") if args.empty?
116
+
88
117
  @filter = args.shift
118
+ return unless valid_pattern?(@filter)
119
+ when "-t", "--testcase"
120
+ return usage_error("#{arg} requires a value") if args.empty?
121
+
122
+ @testcase = args.shift
123
+ return unless valid_pattern?(@testcase)
124
+ when "--ruby" then @ruby = true
89
125
  when "-I"
90
126
  return usage_error("-I requires a value") if args.empty?
91
127
 
92
128
  @includes << File.expand_path(args.shift, @root)
93
129
  when /\A-I(.+)\z/ then @includes << File.expand_path($1, @root)
94
- when /\A--name=(.*)\z/ then @filter = $1
130
+ when /\A--name=(.*)\z/
131
+ @filter = $1
132
+ return unless valid_pattern?(@filter)
133
+ when /\A--testcase=(.*)\z/
134
+ @testcase = $1
135
+ return unless valid_pattern?(@testcase)
95
136
  when "--" then @paths.concat(args.shift(args.size))
96
137
  when /\A-/ then return usage_error("unknown option: #{arg}")
97
138
  else @paths << arg
@@ -99,6 +140,16 @@ module SpinelTest
99
140
  end
100
141
  end
101
142
 
143
+ def valid_pattern?(value)
144
+ return true unless value.length >= 2 && value.start_with?("/") && value.end_with?("/")
145
+
146
+ Regexp.new(value[1..-2])
147
+ true
148
+ rescue RegexpError => e
149
+ usage_error("invalid regexp #{value}: #{e.message}")
150
+ false
151
+ end
152
+
102
153
  def usage_error(message)
103
154
  warn "spinel_test-unit: #{message}"
104
155
  warn USAGE
@@ -134,6 +185,7 @@ module SpinelTest
134
185
  warn "spinel_test-unit: #{display(file)}: #{name} inherits from #{parent}, which is not defined in a scanned file; its tests are ignored"
135
186
  end
136
187
 
188
+ result.classes.each { |c| c.locations.transform_values! { |location| display(location) } }
137
189
  by_file = result.classes.group_by(&:file)
138
190
  order = []
139
191
  files.each do |file|
@@ -219,7 +271,13 @@ module SpinelTest
219
271
 
220
272
  def compile(spinel, main, bin, entries)
221
273
  cmd = [spinel] + Toolchain.compile_flags + include_dirs(entries).flat_map { |d| ["-I", d] } + [main, "-o", bin]
222
- output, status = Open3.capture2e(*cmd)
274
+ begin
275
+ output, status = Open3.capture2e(*cmd)
276
+ rescue SystemCallError => e
277
+ hint = ENV["SPINEL"].to_s.empty? ? "" : " (check SPINEL)"
278
+ warn "spinel_test-unit: cannot run Spinel: #{e.message}#{hint}"
279
+ return false
280
+ end
223
281
  unless status.success?
224
282
  warn output unless output.empty?
225
283
  warn "spinel_test-unit: compilation failed (#{cmd.join(" ")})"
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpinelTest
4
+ module Unit
5
+ class Diff
6
+ MAX_TABLE_CELLS = 1_000_000
7
+
8
+ def self.lines(expected, actual, context = 2)
9
+ new(expected.split("\n", -1), actual.split("\n", -1), context).render
10
+ end
11
+
12
+ def initialize(expected_lines, actual_lines, context)
13
+ @expected_lines = expected_lines
14
+ @actual_lines = actual_lines
15
+ @context = context
16
+ @prefix = common_prefix_length
17
+ @suffix = common_suffix_length
18
+ @expected_middle = middle(expected_lines)
19
+ @actual_middle = middle(actual_lines)
20
+ end
21
+
22
+ def render
23
+ if @expected_middle.length * @actual_middle.length > MAX_TABLE_CELLS
24
+ return " (diff skipped: #{@expected_middle.length} expected and #{@actual_middle.length} actual lines differ)"
25
+ end
26
+
27
+ entries = unchanged(@expected_lines[0, @prefix]) + edits + unchanged(@expected_lines[@expected_lines.length - @suffix, @suffix])
28
+ collapse(entries)
29
+ end
30
+
31
+ private
32
+
33
+ def common_prefix_length
34
+ limit = @expected_lines.length < @actual_lines.length ? @expected_lines.length : @actual_lines.length
35
+ count = 0
36
+ count += 1 while count < limit && @expected_lines[count] == @actual_lines[count]
37
+ count
38
+ end
39
+
40
+ def common_suffix_length
41
+ limit = (@expected_lines.length < @actual_lines.length ? @expected_lines.length : @actual_lines.length) - @prefix
42
+ count = 0
43
+ while count < limit && @expected_lines[@expected_lines.length - 1 - count] == @actual_lines[@actual_lines.length - 1 - count]
44
+ count += 1
45
+ end
46
+ count
47
+ end
48
+
49
+ def middle(lines)
50
+ lines[@prefix, lines.length - @prefix - @suffix]
51
+ end
52
+
53
+ def unchanged(lines)
54
+ lines.map { |line| " " + display(line) }
55
+ end
56
+
57
+ def display(line)
58
+ ambiguous?(line) ? line.inspect : line
59
+ end
60
+
61
+ def ambiguous?(line)
62
+ line == "" || line == "..." || control?(line) || line.start_with?("\"") ||
63
+ line.start_with?(" ", "\t") || line.end_with?(" ", "\t")
64
+ end
65
+
66
+ def control?(line)
67
+ line.bytes.any? { |b| (b < 32 && b != 9) || b == 127 }
68
+ end
69
+
70
+ def edits
71
+ lengths = lcs_lengths
72
+ entries = []
73
+ expected_index = 0
74
+ actual_index = 0
75
+ while expected_index < @expected_middle.length && actual_index < @actual_middle.length
76
+ if @expected_middle[expected_index] == @actual_middle[actual_index]
77
+ entries.push(" " + display(@expected_middle[expected_index]))
78
+ expected_index += 1
79
+ actual_index += 1
80
+ elsif lengths[expected_index + 1][actual_index] >= lengths[expected_index][actual_index + 1]
81
+ entries.push("- " + display(@expected_middle[expected_index]))
82
+ expected_index += 1
83
+ else
84
+ entries.push("+ " + display(@actual_middle[actual_index]))
85
+ actual_index += 1
86
+ end
87
+ end
88
+ push_rest(entries, "- ", @expected_middle, expected_index)
89
+ push_rest(entries, "+ ", @actual_middle, actual_index)
90
+ entries
91
+ end
92
+
93
+ def push_rest(entries, marker, lines, from)
94
+ index = from
95
+ while index < lines.length
96
+ entries.push(marker + display(lines[index]))
97
+ index += 1
98
+ end
99
+ end
100
+
101
+ def lcs_lengths
102
+ lengths = Array.new(@expected_middle.length + 1) { Array.new(@actual_middle.length + 1, 0) }
103
+ expected_index = @expected_middle.length - 1
104
+ while expected_index >= 0
105
+ actual_index = @actual_middle.length - 1
106
+ while actual_index >= 0
107
+ if @expected_middle[expected_index] == @actual_middle[actual_index]
108
+ lengths[expected_index][actual_index] = lengths[expected_index + 1][actual_index + 1] + 1
109
+ else
110
+ down = lengths[expected_index + 1][actual_index]
111
+ right = lengths[expected_index][actual_index + 1]
112
+ lengths[expected_index][actual_index] = down >= right ? down : right
113
+ end
114
+ actual_index -= 1
115
+ end
116
+ expected_index -= 1
117
+ end
118
+ lengths
119
+ end
120
+
121
+ def collapse(entries)
122
+ visible = visible_entries(entries)
123
+ out = []
124
+ index = 0
125
+ while index < entries.length
126
+ run_end = hidden_run_end(visible, index)
127
+ if run_end - index >= 2
128
+ out.push(" ...")
129
+ index = run_end
130
+ else
131
+ out.push(entries[index])
132
+ index += 1
133
+ end
134
+ end
135
+ out.join("\n")
136
+ end
137
+
138
+ def hidden_run_end(visible, from)
139
+ index = from
140
+ index += 1 while index < visible.length && !visible[index]
141
+ index
142
+ end
143
+
144
+ def visible_entries(entries)
145
+ visible = Array.new(entries.length, false)
146
+ entries.each_with_index do |entry, index|
147
+ next if entry.start_with?(" ")
148
+
149
+ first = index - @context
150
+ first = 0 if first < 0
151
+ last = index + @context
152
+ last = entries.length - 1 if last >= entries.length
153
+ first.upto(last) { |position| visible[position] = true }
154
+ end
155
+ visible
156
+ end
157
+ end
158
+ end
159
+ end
@@ -4,16 +4,18 @@ module SpinelTest
4
4
  module Unit
5
5
  module Generator
6
6
  # files: [{ require: "test/foo_test", classes: [TestClass, ...] }, ...]
7
- def self.generate(files)
7
+ # root: the directory the failure locations are shown relative to.
8
+ def self.generate(files, root = "")
8
9
  out = +"require \"spinel_test/unit\"\n"
9
10
  files.each { |f| out << "require #{f[:require].dump}\n" }
10
11
  out << ARGV_PARSER
11
- out << "runner = SpinelTest::Unit::Runner.new(filter)\n"
12
+ out << "runner = SpinelTest::Unit::Runner.new(filter, #{root.dump}, testcase)\n"
12
13
  files.each do |f|
13
14
  f[:classes].each do |klass|
15
+ locations = klass.locations || {}
14
16
  klass.methods.each do |m|
15
17
  out << "t = #{klass.name}.new\n"
16
- out << "runner.run(#{"#{klass.name}##{m}".dump}, t) { t.#{m} }\n"
18
+ out << "runner.run(#{"#{klass.name}##{m}".dump}, t, #{locations[m].to_s.dump}) { t.#{m} }\n"
17
19
  end
18
20
  end
19
21
  end
@@ -23,11 +25,15 @@ module SpinelTest
23
25
 
24
26
  ARGV_PARSER = <<~RUBY
25
27
  filter = ""
28
+ testcase = ""
26
29
  i = 0
27
30
  while i < ARGV.length
28
31
  if ARGV[i] == "-n" && i + 1 < ARGV.length
29
32
  filter = ARGV[i + 1]
30
33
  i += 1
34
+ elsif ARGV[i] == "-t" && i + 1 < ARGV.length
35
+ testcase = ARGV[i + 1]
36
+ i += 1
31
37
  end
32
38
  i += 1
33
39
  end
@@ -3,8 +3,13 @@
3
3
  module SpinelTest
4
4
  module Unit
5
5
  class Runner
6
- def initialize(filter = "")
6
+ MAX_FRAMES = 3
7
+ FRAMEWORK_DIR = File.dirname(File.expand_path(__FILE__)) + "/"
8
+
9
+ def initialize(filter = "", root = "", testcase = "")
7
10
  @filter = filter
11
+ @root = root
12
+ @testcase = testcase
8
13
  @tests = 0
9
14
  @assertions = 0
10
15
  @failures = 0
@@ -13,27 +18,46 @@ module SpinelTest
13
18
  end
14
19
 
15
20
  def selected?(name)
16
- @filter == "" || name.include?(@filter)
21
+ index = name.index("#")
22
+ klass = index ? name[0, index] : name
23
+ matches?(@testcase, klass, true) && matches?(@filter, name, false)
24
+ end
25
+
26
+ def matches?(filter, text, exact)
27
+ return true if filter == ""
28
+ return Regexp.new(filter[1, filter.length - 2]).match?(text) if pattern?(filter)
29
+
30
+ exact ? text == filter : text.include?(filter)
31
+ end
32
+
33
+ def pattern?(filter)
34
+ filter.length >= 2 && filter.start_with?("/") && filter.end_with?("/")
17
35
  end
18
36
 
19
37
  # Runs one test: setup, the block (which calls the test method), teardown.
20
- def run(name, tc)
38
+ # location is the "file:line" of the test method, shown when the exception
39
+ # has no backtrace of its own.
40
+ def run(name, tc, location = "")
21
41
  return unless selected?(name)
22
42
 
23
43
  @tests += 1
24
44
  status = 0
25
45
  message = ""
46
+ frames = []
47
+ teardown_message = ""
26
48
  begin
27
49
  tc.setup
28
50
  yield
29
51
  rescue AssertionFailedError => e
30
52
  status = 1
31
53
  message = e.message
54
+ frames = report_frames(e, location)
32
55
  rescue Exception => e
33
56
  raise e if fatal?(e)
34
57
 
35
58
  status = 2
36
59
  message = e.message
60
+ frames = report_frames(e, location)
37
61
  end
38
62
  begin
39
63
  tc.teardown
@@ -43,8 +67,9 @@ module SpinelTest
43
67
  if status == 0
44
68
  status = 2
45
69
  message = e.message
70
+ frames = report_frames(e, location)
46
71
  else
47
- message = message + "\nTeardown also raised: " + e.message
72
+ teardown_message = "\nTeardown also raised: " + e.message
48
73
  end
49
74
  end
50
75
  @assertions += tc.assertions
@@ -53,19 +78,69 @@ module SpinelTest
53
78
  elsif status == 1
54
79
  print "F"
55
80
  @failures += 1
56
- @details.push("Failure: #{name}\n#{message}")
81
+ @details.push("Failure: #{name}\n#{message}#{where(frames)}#{teardown_message}")
57
82
  else
58
83
  print "E"
59
84
  @errors += 1
60
- @details.push("Error: #{name}\n#{message}")
85
+ @details.push("Error: #{name}\n#{message}#{where(frames)}#{teardown_message}")
61
86
  end
62
87
  end
63
88
 
89
+ def report_frames(e, location)
90
+ backtrace = e.backtrace || []
91
+ frames = []
92
+ backtrace.each do |frame|
93
+ next if framework_frame?(frame)
94
+ next unless frames.empty? || under_root?(frame)
95
+
96
+ shown = relative_path(frame_location(frame))
97
+ next if frames.length > 0 && frames[frames.length - 1] == shown
98
+
99
+ frames.push(shown)
100
+ break if frames.length >= MAX_FRAMES
101
+ end
102
+ frames.push(location) if frames.empty? && location != ""
103
+ frames
104
+ end
105
+
106
+ def under_root?(frame)
107
+ @root == "" || frame.start_with?(@root + "/")
108
+ end
109
+
110
+ def framework_frame?(frame)
111
+ frame.start_with?(FRAMEWORK_DIR) || frame.start_with?($0 + ":")
112
+ end
113
+
114
+ def frame_location(frame)
115
+ index = frame.index(":in ")
116
+ index ? frame[0, index] : frame
117
+ end
118
+
119
+ def relative_path(location)
120
+ return location if @root == ""
121
+
122
+ prefix = @root + "/"
123
+ location.start_with?(prefix) ? location[prefix.length, location.length] : location
124
+ end
125
+
126
+ def where(frames)
127
+ text = ""
128
+ frames.each { |frame| text += "\n at " + frame }
129
+ text
130
+ end
131
+
64
132
  # Exceptions that must end the process instead of being reported as a test error.
65
133
  def fatal?(e)
66
134
  e.is_a?(SystemExit) || e.is_a?(Interrupt)
67
135
  end
68
136
 
137
+ def filter_description
138
+ parts = []
139
+ parts.push("testcase: " + @testcase) if @testcase != ""
140
+ parts.push("filter: " + @filter) if @filter != ""
141
+ parts.empty? ? "" : " (" + parts.join(", ") + ")"
142
+ end
143
+
69
144
  def passed?
70
145
  @failures == 0 && @errors == 0
71
146
  end
@@ -84,7 +159,7 @@ module SpinelTest
84
159
  puts ""
85
160
  puts summary
86
161
  if @tests == 0
87
- puts "No tests were run#{@filter == "" ? "" : " (filter: " + @filter + ")"}"
162
+ puts "No tests were run#{filter_description}"
88
163
  return 1
89
164
  end
90
165
  passed? ? 0 : 1
@@ -8,10 +8,10 @@ module SpinelTest
8
8
  VISIBILITY = %i[private protected public].freeze
9
9
  BASE_NAMES = ["SpinelTest::Unit::TestCase", "::SpinelTest::Unit::TestCase"].freeze
10
10
 
11
- TestClass = Struct.new(:name, :methods, :file, :files, keyword_init: true)
11
+ TestClass = Struct.new(:name, :methods, :file, :files, :locations, keyword_init: true)
12
12
  # superclass is the raw name as written; scope is the lexical namespace it
13
13
  # was written in. Both are qualified in resolve, once every class is known.
14
- ClassInfo = Struct.new(:name, :superclass, :methods, :files, :hidden, :scope)
14
+ ClassInfo = Struct.new(:name, :superclass, :methods, :files, :hidden, :scope, :locations)
15
15
  Result = Struct.new(:classes, :warnings, keyword_init: true)
16
16
 
17
17
  def self.scan(sources)
@@ -68,7 +68,7 @@ module SpinelTest
68
68
  visit(node.body, namespace + [path_name(node.constant_path)]) if node.body
69
69
  when Prism::ClassNode
70
70
  name = (namespace + [path_name(node.constant_path)]).join("::")
71
- info = (@classes[name] ||= ClassInfo.new(name, nil, [], []))
71
+ info = (@classes[name] ||= ClassInfo.new(name, nil, [], [], nil, nil, {}))
72
72
  info.files << @file if @file && !info.files.include?(@file)
73
73
  if node.superclass
74
74
  info.superclass = path_name(node.superclass)
@@ -107,7 +107,10 @@ module SpinelTest
107
107
  name = stmt.name.to_s
108
108
  next if info.hidden&.include?(name)
109
109
 
110
- info.methods << name if name.start_with?("test_") && !info.methods.include?(name)
110
+ next unless name.start_with?("test_")
111
+
112
+ info.methods << name unless info.methods.include?(name)
113
+ info.locations[name] = "#{@file}:#{stmt.location.start_line}" if @file
111
114
  end
112
115
  end
113
116
 
@@ -148,7 +151,8 @@ module SpinelTest
148
151
 
149
152
  files = chain.reverse.flat_map(&:files).uniq
150
153
  file = (targets && info.files.find { |f| targets.include?(f) }) || info.files.first
151
- TestClass.new(name: info.name, methods: methods, file: file, files: files)
154
+ locations = chain.reverse.reduce({}) { |all, link| all.merge(link.locations) }
155
+ TestClass.new(name: info.name, methods: methods, file: file, files: files, locations: locations)
152
156
  end
153
157
  end
154
158
 
@@ -27,34 +27,67 @@ module SpinelTest
27
27
  raise AssertionFailedError, (message || "Failed assertion, no message given.")
28
28
  end
29
29
 
30
+ def refute(test, message = nil)
31
+ @assertions += 1
32
+ return true unless test
33
+
34
+ raise AssertionFailedError, (message || "Failed refutation, no message given.")
35
+ end
36
+
30
37
  def assert_equal(expected, actual, message = nil)
31
38
  @assertions += 1
32
39
  return true if expected == actual
33
40
 
34
- msg = "Expected: #{expected.inspect}\n Actual: #{actual.inspect}"
35
- msg = message + "\n" + msg if message
36
- raise AssertionFailedError, msg
41
+ failure("Expected: #{expected.inspect}\n Actual: #{actual.inspect}" + diff_section(expected, actual), message)
42
+ end
43
+
44
+ def assert_not_equal(expected, actual, message = nil)
45
+ @assertions += 1
46
+ return true unless expected == actual
47
+
48
+ failure("Expected #{actual.inspect} not to equal #{expected.inspect}", message)
49
+ end
50
+
51
+ def assert_match(pattern, string, message = nil)
52
+ @assertions += 1
53
+ return true if pattern_matches?(pattern, string)
54
+
55
+ failure("Expected #{pattern.inspect} to match #{string.inspect}", message)
37
56
  end
38
57
 
39
- def assert_nil(v, message = nil)
58
+ def assert_nil(value, message = nil)
40
59
  @assertions += 1
41
- return true if v.nil?
60
+ return true if value.nil?
42
61
 
43
- msg = "Expected nil, got #{v.inspect}"
44
- msg = message + "\n" + msg if message
45
- raise AssertionFailedError, msg
62
+ failure("Expected nil, got #{value.inspect}", message)
46
63
  end
47
64
 
48
- def assert_includes(coll, item, message = nil)
65
+ def assert_not_nil(value, message = nil)
49
66
  @assertions += 1
50
- return true if coll.include?(item)
67
+ return true unless value.nil?
51
68
 
52
- msg = "#{coll.inspect} does not include #{item.inspect}"
53
- msg = message + "\n" + msg if message
54
- raise AssertionFailedError, msg
69
+ failure("Expected a value other than nil", message)
55
70
  end
56
71
 
57
- def assert_raise(klass)
72
+ def assert_include(collection, item, message = nil)
73
+ @assertions += 1
74
+ return true if collection.include?(item)
75
+
76
+ failure("#{collection.inspect} does not include #{item.inspect}", message)
77
+ end
78
+
79
+ def assert_includes(collection, item, message = nil)
80
+ assert_include(collection, item, message)
81
+ end
82
+
83
+ def assert_not_include(collection, item, message = nil)
84
+ @assertions += 1
85
+ return true unless collection.include?(item)
86
+
87
+ failure("#{collection.inspect} includes #{item.inspect}", message)
88
+ end
89
+
90
+ def assert_raise(klass, match: nil)
58
91
  @assertions += 1
59
92
  begin
60
93
  yield
@@ -62,12 +95,54 @@ module SpinelTest
62
95
  # a failed assertion inside the block is not "the expected error"
63
96
  raise e
64
97
  rescue StandardError => e
65
- return e if e.is_a?(klass)
98
+ return expected_exception(e, klass, match, e.instance_of?(klass))
99
+ end
100
+ raise AssertionFailedError, "Expected #{klass} but nothing was raised"
101
+ end
66
102
 
67
- raise AssertionFailedError, "Expected #{klass} but got #{e.class}"
103
+ def assert_raise_kind_of(klass, match: nil)
104
+ @assertions += 1
105
+ begin
106
+ yield
107
+ rescue AssertionFailedError => e
108
+ raise e
109
+ rescue StandardError => e
110
+ return expected_exception(e, klass, match, e.is_a?(klass))
68
111
  end
69
112
  raise AssertionFailedError, "Expected #{klass} but nothing was raised"
70
113
  end
114
+
115
+ private
116
+
117
+ def failure(text, message)
118
+ raise AssertionFailedError, message ? message + "\n" + text : text
119
+ end
120
+
121
+ def expected_exception(e, klass, match, class_matches)
122
+ raise AssertionFailedError, "Expected #{klass} but got #{e.class}" unless class_matches
123
+ return e if match.nil? || pattern_matches?(match, e.message)
124
+
125
+ raise AssertionFailedError, "Expected #{klass} with a message matching #{match.inspect} but got #{e.message.inspect}"
126
+ end
127
+
128
+ def diff_section(expected, actual)
129
+ return "" unless multiline_strings?(expected, actual)
130
+
131
+ "\nDiff:\n" + Diff.lines(expected, actual)
132
+ rescue ArgumentError, EncodingError
133
+ ""
134
+ end
135
+
136
+ def multiline_strings?(expected, actual)
137
+ expected.is_a?(String) && actual.is_a?(String) && expected.include?("\n") && actual.include?("\n")
138
+ end
139
+
140
+ def pattern_matches?(pattern, string)
141
+ return false unless string.is_a?(String)
142
+ return pattern.match?(string) if pattern.is_a?(Regexp)
143
+
144
+ string.include?(pattern)
145
+ end
71
146
  end
72
147
  end
73
148
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SpinelTest
4
4
  module Unit
5
- VERSION = "0.1.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "unit/version"
4
+ require_relative "unit/diff"
4
5
  require_relative "unit/test_case"
5
6
  require_relative "unit/runner"
6
7
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spinel_test-unit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuji Yaginuma
@@ -58,6 +58,7 @@ files:
58
58
  - exe/spinel_test-unit
59
59
  - lib/spinel_test/unit.rb
60
60
  - lib/spinel_test/unit/cli.rb
61
+ - lib/spinel_test/unit/diff.rb
61
62
  - lib/spinel_test/unit/generator.rb
62
63
  - lib/spinel_test/unit/runner.rb
63
64
  - lib/spinel_test/unit/scanner.rb
@@ -70,6 +71,7 @@ licenses:
70
71
  metadata:
71
72
  homepage_uri: https://github.com/y-yagi/spinel_test-unit
72
73
  source_code_uri: https://github.com/y-yagi/spinel_test-unit
74
+ rubygems_mfa_required: 'true'
73
75
  rdoc_options: []
74
76
  require_paths:
75
77
  - lib