brutal 1.1.1 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/LICENSE.md +2 -2
- data/README.md +42 -38
- data/bin/brutal +2 -2
- data/lib/brutal/configuration.rb +12 -7
- data/lib/brutal/file/read.rb +1 -6
- data/lib/brutal/file/write.rb +2 -7
- data/lib/brutal/file.rb +20 -0
- data/lib/brutal/scaffold.rb +7 -10
- data/lib/brutal/yaml.rb +11 -1
- data/lib/brutal.rb +16 -7
- metadata +48 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7f98c650c2e97438b0cedcebdb5b01da84af29f7cbc0c324a4b3416b3da68114
|
4
|
+
data.tar.gz: 696e8e96639a62e046f138c10f3d5181a3265c90bb10306481bc0d2b2fd9d3d0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b6279a503eba6120d29cd8855fab48c725f46671bd8efa7d6fd73158e68d0cf6de9fed01e4f5ae83cf04417b0551ab9598d6c615d25b164fbefec1d3b7ce262f
|
7
|
+
data.tar.gz: 8aebd74fd0a2c9bda6b418980dd627645f9b1c85f3e92e753ba2c03bd642d0dd63393dfd8923ef25e470269e3b6645fa3428a54cd7b2128ca53ee2b5c9e5de63
|
data/LICENSE.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
The MIT License
|
1
|
+
# The MIT License
|
2
2
|
|
3
|
-
Copyright (c) 2020 Cyril Kato
|
3
|
+
Copyright (c) 2020-2022 Cyril Kato
|
4
4
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
@@ -1,53 +1,57 @@
|
|
1
1
|
# Brutal 💎🔨
|
2
2
|
|
3
|
-
[![
|
4
|
-
[![
|
5
|
-
[![
|
6
|
-
[![
|
7
|
-
[![
|
3
|
+
[![Version](https://img.shields.io/github/v/tag/fixrb/brutal?label=Version&logo=github)](https://github.com/fixrb/brutal/tags)
|
4
|
+
[![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/brutal/main)
|
5
|
+
[![Ruby](https://github.com/fixrb/brutal/workflows/Ruby/badge.svg?branch=main)](https://github.com/fixrb/brutal/actions?query=workflow%3Aruby+branch%3Amain)
|
6
|
+
[![RuboCop](https://github.com/fixrb/brutal/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/brutal/actions?query=workflow%3Arubocop+branch%3Amain)
|
7
|
+
[![License](https://img.shields.io/github/license/fixrb/brutal?label=License&logo=github)](https://github.com/fixrb/brutal/raw/main/LICENSE.md)
|
8
8
|
|
9
9
|
> A _code-first_ approach to automate the writing of unit tests.
|
10
10
|
|
11
11
|
## Intro
|
12
12
|
|
13
|
-
[![I Hate Tests](https://github.com/fixrb/brutal/raw/
|
13
|
+
[![I Hate Tests](https://github.com/fixrb/brutal/raw/main/img/rubyhack-2019-ruby3-what-s-missing-by-yukihiro-matsumoto.jpg)](https://www.youtube.com/embed/cmOt9HhszCI?start=1732&end=1736 "I don't like tests. It's not DRY.")
|
14
14
|
|
15
|
-
> I don't like tests. It's not DRY
|
15
|
+
> I don't like tests. It's not DRY.
|
16
16
|
> -- [Matz](https://github.com/matz)
|
17
17
|
|
18
18
|
## Overview
|
19
19
|
|
20
|
-
Let __Brutal__
|
20
|
+
Let __Brutal__ craft for you in no time a (potentially huge) framework-less vanilla Ruby file describing the actual behavior of your code across as many context combinations as necessary.
|
21
21
|
|
22
|
-
By delegating to __Brutal__ the repetitive (and redundant) task of writing tests, you
|
22
|
+
By delegating to __Brutal__ the repetitive (and redundant) task of writing tests, you will be able to focus on your core business: the code itself.
|
23
|
+
|
24
|
+
![Brutal-Driven Development](https://github.com/fixrb/brutal/raw/main/img/brutal-driven-development.jpg)
|
23
25
|
|
24
26
|
## Warning
|
25
27
|
|
26
|
-
|
28
|
+
The _Brutal-Driven Development_ process does not prevent bugs from appearing in the code.
|
29
|
+
|
30
|
+
A generated test suite acts as a _picture of the code's behavior_. Therefore, if the code is wrong, the picture of the code's behavior will also be wrong.
|
27
31
|
|
28
|
-
|
29
|
-
a generated test suite is wrong as long as the code is wrong,
|
30
|
-
regardless of whether all true expectations.
|
32
|
+
The mere fact that all expectations are true does not mean that the code behaves as it should.
|
31
33
|
|
32
|
-
|
33
|
-
It is therefore important to read it well.
|
34
|
-
This is the price for _Brutal-Driven Development_.
|
34
|
+
It is therefore the responsibility of the developer to analyze the generated behavioral pictures to ensure that the code reacts as it is supposed to according to the contexts in which it is evaluated.
|
35
35
|
|
36
36
|
## Installation
|
37
37
|
|
38
38
|
Add this line to your application's Gemfile:
|
39
39
|
|
40
40
|
```ruby
|
41
|
-
gem
|
41
|
+
gem "brutal"
|
42
42
|
```
|
43
43
|
|
44
44
|
And then execute:
|
45
45
|
|
46
|
-
|
46
|
+
```sh
|
47
|
+
bundle install
|
48
|
+
```
|
47
49
|
|
48
50
|
Or install it yourself as:
|
49
51
|
|
50
|
-
|
52
|
+
```sh
|
53
|
+
gem install brutal
|
54
|
+
```
|
51
55
|
|
52
56
|
## Quick Start
|
53
57
|
|
@@ -55,18 +59,22 @@ Just type `brutal` in a Ruby project's folder and watch the magic happen.
|
|
55
59
|
|
56
60
|
## Usage
|
57
61
|
|
58
|
-
|
59
|
-
|
62
|
+
__Brutal__ needs a configuration file, it's a kind of manifest, or a meta-spec if you prefer.
|
63
|
+
This file is composed of 4 top-level sections:
|
60
64
|
|
61
65
|
* `header` - Specifies the code to execute before generating the test suite.
|
62
66
|
* `subject` - Specifies the template of the code to be declined across contexts.
|
63
67
|
* `contexts` - Specifies a list of variables to populate the subject's template.
|
64
68
|
* `actuals` - Specifies templates to challenge evaluated subjects & get results.
|
65
69
|
|
70
|
+
By default, this file is called `.brutal.yml`, but it would be possible to call it differently by passing it as an argument to the brutal command.
|
71
|
+
|
72
|
+
Currently, only the YAML format is supported.
|
73
|
+
|
66
74
|
### Getting started
|
67
75
|
|
68
|
-
1. Create a
|
69
|
-
The following example
|
76
|
+
1. Create a `.brutal.yml` file in your application's root directory.
|
77
|
+
The following example `.brutal.yml` defines the shape of a Hello test suite:
|
70
78
|
|
71
79
|
```yaml
|
72
80
|
---
|
@@ -111,7 +119,7 @@ raise if actual.length != 9
|
|
111
119
|
|
112
120
|
### More examples
|
113
121
|
|
114
|
-
https://github.com/fixrb/brutal/raw/
|
122
|
+
https://github.com/fixrb/brutal/raw/main/examples/
|
115
123
|
|
116
124
|
## Rake integration example
|
117
125
|
|
@@ -119,10 +127,14 @@ A generated `test.rb` file could be matched as follows:
|
|
119
127
|
|
120
128
|
```ruby
|
121
129
|
Rake::TestTask.new do |t|
|
122
|
-
t.pattern =
|
130
|
+
t.pattern = "test.rb"
|
123
131
|
end
|
124
132
|
```
|
125
133
|
|
134
|
+
## Test suite
|
135
|
+
|
136
|
+
__Brutal__'s test set is brutally self-generated here: [./test.rb](https://github.com/fixrb/brutal/blob/main/test.rb)
|
137
|
+
|
126
138
|
## Contact
|
127
139
|
|
128
140
|
* Source code: https://github.com/fixrb/brutal
|
@@ -133,19 +145,11 @@ __Brutal__ follows [Semantic Versioning 2.0](https://semver.org/).
|
|
133
145
|
|
134
146
|
## License
|
135
147
|
|
136
|
-
The [gem](https://rubygems.org/gems/brutal) is available as open source under the terms of the [MIT License](https://
|
148
|
+
The [gem](https://rubygems.org/gems/brutal) is available as open source under the terms of the [MIT License](https://github.com/fixrb/brutal/raw/main/LICENSE.md).
|
137
149
|
|
138
150
|
***
|
139
151
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
alt="Sashite" /></a>
|
145
|
-
</p>
|
146
|
-
|
147
|
-
[workflow_rubocop]: https://github.com/fixrb/brutal/actions?query=workflow%3ARuboCop
|
148
|
-
[gem]: https://rubygems.org/gems/brutal
|
149
|
-
[travis]: https://travis-ci.org/fixrb/brutal
|
150
|
-
[inchpages]: https://inch-ci.org/github/fixrb/brutal
|
151
|
-
[rubydoc]: https://rubydoc.info/gems/brutal/frames
|
152
|
+
This project is sponsored by [Sashité](https://github.com/sashite/):
|
153
|
+
|
154
|
+
![Sashité logo](https://github.com/fixrb/brutal/raw/main/img/sponsor/dark/en/sashite.png#gh-dark-mode-only "Sashité")
|
155
|
+
![Sashité logo](https://github.com/fixrb/brutal/raw/main/img/sponsor/light/en/sashite.png#gh-light-mode-only "Sashité")
|
data/bin/brutal
CHANGED
data/lib/brutal/configuration.rb
CHANGED
@@ -5,20 +5,25 @@ module Brutal
|
|
5
5
|
#
|
6
6
|
# @since 1.0.0
|
7
7
|
class Configuration
|
8
|
+
ACTUALS_KEY = "actuals"
|
9
|
+
CONTEXTS_KEY = "contexts"
|
10
|
+
HEADER_KEY = "header"
|
11
|
+
SUBJECT_KEY = "subject"
|
12
|
+
|
8
13
|
DEFAULT_ACTUALS = [].freeze
|
9
14
|
DEFAULT_CONTEXTS = {}.freeze
|
10
|
-
|
11
|
-
DEFAULT_SUBJECT =
|
15
|
+
DEFAULT_HEADER = "# Brutal test suite"
|
16
|
+
DEFAULT_SUBJECT = ""
|
12
17
|
|
13
18
|
# Load the configuration parameters.
|
14
19
|
#
|
15
20
|
# @param params [Hash] Receive the 4 top-level section parameters.
|
16
21
|
def self.load(params)
|
17
22
|
new(
|
18
|
-
actuals:
|
19
|
-
contexts: params.fetch(
|
20
|
-
header:
|
21
|
-
subject:
|
23
|
+
actuals: params.fetch(ACTUALS_KEY, DEFAULT_ACTUALS),
|
24
|
+
contexts: params.fetch(CONTEXTS_KEY, DEFAULT_CONTEXTS),
|
25
|
+
header: params.fetch(HEADER_KEY, DEFAULT_HEADER),
|
26
|
+
subject: params.fetch(SUBJECT_KEY, DEFAULT_SUBJECT)
|
22
27
|
)
|
23
28
|
end
|
24
29
|
|
@@ -41,7 +46,7 @@ module Brutal
|
|
41
46
|
raise ::TypeError, header.inspect unless header.is_a?(::String)
|
42
47
|
raise ::TypeError, subject.inspect unless subject.is_a?(::String)
|
43
48
|
|
44
|
-
@actuals = actuals
|
49
|
+
@actuals = actuals.sort
|
45
50
|
@contexts = contexts
|
46
51
|
@header = header
|
47
52
|
@subject = subject
|
data/lib/brutal/file/read.rb
CHANGED
@@ -1,19 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Brutal
|
4
|
-
# Brutal::File
|
5
|
-
#
|
6
|
-
# @since 1.1.0
|
7
4
|
module File
|
8
5
|
# Brutal::File::Read
|
9
6
|
#
|
10
7
|
# @since 1.1.0
|
11
8
|
class Read
|
12
|
-
NAME = '.brutal.yml'
|
13
|
-
|
14
9
|
attr_reader :name
|
15
10
|
|
16
|
-
def initialize(name
|
11
|
+
def initialize(name)
|
17
12
|
@name = name
|
18
13
|
end
|
19
14
|
|
data/lib/brutal/file/write.rb
CHANGED
@@ -1,24 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Brutal
|
4
|
-
# Brutal::File
|
5
|
-
#
|
6
|
-
# @since 1.1.0
|
7
4
|
module File
|
8
5
|
# Brutal::File::Write
|
9
6
|
#
|
10
7
|
# @since 1.1.0
|
11
8
|
class Write
|
12
|
-
NAME = 'test.rb'
|
13
|
-
|
14
9
|
attr_reader :name
|
15
10
|
|
16
|
-
def initialize(name
|
11
|
+
def initialize(name)
|
17
12
|
@name = name
|
18
13
|
end
|
19
14
|
|
20
15
|
def call(scaffold)
|
21
|
-
file = ::File.open(path,
|
16
|
+
file = ::File.open(path, "w")
|
22
17
|
file.write(scaffold)
|
23
18
|
|
24
19
|
true
|
data/lib/brutal/file.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
%w[
|
4
|
+
read
|
5
|
+
write
|
6
|
+
].each { |filename| require_relative(File.join("file", filename)) }
|
7
|
+
|
8
|
+
module Brutal
|
9
|
+
# Brutal::File
|
10
|
+
module File
|
11
|
+
DEFAULT_CONFIG_FILENAME = ".brutal.yml"
|
12
|
+
DEFAULT_GENERATED_FILENAME = "test.rb"
|
13
|
+
|
14
|
+
def self.generated_filename(filename)
|
15
|
+
return DEFAULT_GENERATED_FILENAME if filename == DEFAULT_CONFIG_FILENAME
|
16
|
+
|
17
|
+
filename.gsub(/.[^.]+\z/, ".rb")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/brutal/scaffold.rb
CHANGED
@@ -19,9 +19,9 @@ module Brutal
|
|
19
19
|
|
20
20
|
# Initialize a new scaffold generator.
|
21
21
|
def initialize(header, subject, *actuals, **contexts)
|
22
|
-
warn(
|
23
|
-
warn(
|
24
|
-
warn(
|
22
|
+
warn("Empty subject!") if subject.empty?
|
23
|
+
warn("Empty actual values!") if actuals.empty?
|
24
|
+
warn("Empty contexts!") if contexts.empty?
|
25
25
|
|
26
26
|
eval(header) # rubocop:disable Security/Eval
|
27
27
|
|
@@ -41,8 +41,6 @@ module Brutal
|
|
41
41
|
# Return a string representation.
|
42
42
|
#
|
43
43
|
# @return [String]
|
44
|
-
#
|
45
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
46
44
|
def to_s
|
47
45
|
"#{header.chomp}\n#{blank_line}" + combinations_values.map do |values|
|
48
46
|
attributes = context_names.each_with_index.inject({}) do |h, (name, i)|
|
@@ -61,19 +59,18 @@ module Brutal
|
|
61
59
|
actual = eval(actual_str) # rubocop:disable Security/Eval, Lint/UselessAssignment
|
62
60
|
|
63
61
|
actuals.each do |actual_value|
|
64
|
-
result_str = format(actual_value, subject:
|
62
|
+
result_str = format(actual_value, subject: "actual")
|
65
63
|
string += "raise if #{result_str} != #{eval(result_str).inspect}\n" # rubocop:disable Security/Eval
|
66
64
|
end
|
67
65
|
|
68
66
|
string
|
69
67
|
end.join(blank_line)
|
70
68
|
end
|
71
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
72
69
|
|
73
70
|
def blank_line
|
74
|
-
"\n"
|
75
|
-
|
76
|
-
|
71
|
+
"\n" \
|
72
|
+
"# #{'-' * 78}\n" \
|
73
|
+
"\n"
|
77
74
|
end
|
78
75
|
|
79
76
|
def context_names
|
data/lib/brutal/yaml.rb
CHANGED
@@ -1,14 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
3
|
+
require "yaml"
|
4
4
|
|
5
5
|
module Brutal
|
6
6
|
# Brutal::Yaml
|
7
7
|
#
|
8
8
|
# @since 1.1.0
|
9
9
|
module Yaml
|
10
|
+
FILENAME_EXTENSIONS = %w[
|
11
|
+
yaml
|
12
|
+
yml
|
13
|
+
].freeze
|
14
|
+
|
10
15
|
def self.parse(yaml)
|
11
16
|
::YAML.safe_load(yaml, symbolize_names: false)
|
12
17
|
end
|
18
|
+
|
19
|
+
def self.parse?(filename)
|
20
|
+
filename_extension = filename.split(".")[1..][-1]
|
21
|
+
FILENAME_EXTENSIONS.include?(filename_extension)
|
22
|
+
end
|
13
23
|
end
|
14
24
|
end
|
data/lib/brutal.rb
CHANGED
@@ -2,23 +2,32 @@
|
|
2
2
|
|
3
3
|
%w[
|
4
4
|
configuration
|
5
|
-
file
|
6
|
-
file/write
|
5
|
+
file
|
7
6
|
scaffold
|
8
7
|
yaml
|
9
|
-
].each { |
|
8
|
+
].each { |filename| require_relative(File.join("brutal", filename)) }
|
10
9
|
|
11
10
|
# The Brutal namespace.
|
12
11
|
module Brutal
|
13
|
-
def self.generate!
|
14
|
-
|
15
|
-
|
12
|
+
def self.generate!(filename)
|
13
|
+
file = File::Read.new(filename).call
|
14
|
+
|
15
|
+
hash = if Yaml.parse?(filename)
|
16
|
+
Yaml.parse(file)
|
17
|
+
else
|
18
|
+
raise ::ArgumentError, "Unrecognized extension. " \
|
19
|
+
"Impossible to parse #{filename.inspect}."
|
20
|
+
end
|
21
|
+
|
16
22
|
conf = Configuration.load(hash)
|
23
|
+
|
17
24
|
ruby = Scaffold.new(conf.header,
|
18
25
|
conf.subject,
|
19
26
|
*conf.actuals,
|
20
27
|
**conf.contexts)
|
21
28
|
|
22
|
-
File
|
29
|
+
new_filename = File.generated_filename(filename)
|
30
|
+
|
31
|
+
File::Write.new(new_filename).call(ruby)
|
23
32
|
end
|
24
33
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: brutal
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-08-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -38,6 +38,34 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rubocop-gitlab-security
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop-md
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
41
69
|
- !ruby/object:Gem::Dependency
|
42
70
|
name: rubocop-performance
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,6 +80,20 @@ dependencies:
|
|
52
80
|
- - ">="
|
53
81
|
- !ruby/object:Gem::Version
|
54
82
|
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop-rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
55
97
|
- !ruby/object:Gem::Dependency
|
56
98
|
name: rubocop-thread_safety
|
57
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -106,6 +148,7 @@ files:
|
|
106
148
|
- bin/brutal
|
107
149
|
- lib/brutal.rb
|
108
150
|
- lib/brutal/configuration.rb
|
151
|
+
- lib/brutal/file.rb
|
109
152
|
- lib/brutal/file/read.rb
|
110
153
|
- lib/brutal/file/write.rb
|
111
154
|
- lib/brutal/scaffold.rb
|
@@ -113,7 +156,8 @@ files:
|
|
113
156
|
homepage: https://github.com/fixrb/brutal
|
114
157
|
licenses:
|
115
158
|
- MIT
|
116
|
-
metadata:
|
159
|
+
metadata:
|
160
|
+
rubygems_mfa_required: 'true'
|
117
161
|
post_install_message:
|
118
162
|
rdoc_options: []
|
119
163
|
require_paths:
|
@@ -129,7 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
173
|
- !ruby/object:Gem::Version
|
130
174
|
version: '0'
|
131
175
|
requirements: []
|
132
|
-
rubygems_version: 3.1.
|
176
|
+
rubygems_version: 3.1.6
|
133
177
|
signing_key:
|
134
178
|
specification_version: 4
|
135
179
|
summary: A code-first approach to automate the writing of unit tests.
|