Skip to content

Commit 561fff6

Browse files
authored
Merge pull request #16 from DataDog/anmarchenko/minitest_support
[SDTEST-2446] Minitest support
2 parents 8b18a64 + ece6948 commit 561fff6

File tree

9 files changed

+859
-93
lines changed

9 files changed

+859
-93
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ You need it when Test Impact Analysis shrinks the test workload but CI still lau
66

77
Currently supported languages and frameworks:
88

9-
- Ruby (RSpec)
9+
- Ruby (RSpec, Minitest)
1010

1111
## Installation
1212

@@ -115,7 +115,7 @@ In CI‑node mode, DDTest also fans out across local CPUs on that node and furth
115115
| CLI flag | Environment variable | Default | What it does |
116116
| ------------------- | --------------------------------------------- | ---------: | --------------------------------------------------------------------------------------------------------------------- |
117117
| `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | `ruby` | Language/platform (currently supported values: `ruby`). |
118-
| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`). |
118+
| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`, `minitest`). |
119119
| `--min-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM` | vCPU count | Minimum workers to use for the split. |
120120
| `--max-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM` | vCPU count | Maximum workers to use for the split. |
121121
| `--ci-node` | `DD_TEST_OPTIMIZATION_RUNNER_CI_NODE` | `-1` (off) | Restrict this run to the slice assigned to node **N** (0‑indexed). Also parallelizes within the node across its CPUs. |
@@ -202,6 +202,21 @@ matrix={"include":[{"ci_node_index":0},{"ci_node_index":1},{"ci_node_index":2}]}
202202
203203
You can cat it to `$GITHUB_OUTPUT` to make it available for the test job.
204204
205+
### Minitest support in non-rails projects
206+
207+
We use `bundle exec rake test` command when we don't detect `rails` command to run tests.
208+
This command doesn't have a built in way to pass the list of files to execute, so we pass
209+
them as a space-separated list of files in `TEST_FILES` environment variable.
210+
211+
You need to use this environment variable in your project to integrate your tests with
212+
`ddtest run`. Example when using `Rake::TestTask`:
213+
214+
```ruby
215+
Rake::TestTask.new(:test) do |test|
216+
test.test_files = ENV["TEST_FILES"] ? ENV["TEST_FILES"].split : ["test/**/*.rb"]
217+
end
218+
```
219+
205220
## Development
206221

207222
### Prerequisites

internal/constants/constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ var SkippablePercentageOutputPath = filepath.Join(PlanDirectory, "skippable-perc
1111
var ParallelRunnersOutputPath = filepath.Join(PlanDirectory, "parallel-runners.txt")
1212
var TestsSplitDir = filepath.Join(PlanDirectory, "tests-split")
1313

14+
// Platform specific output file paths
15+
var RubyEnvOutputPath = filepath.Join(PlanDirectory, "ruby_env.json")
16+
1417
// Executor constants
1518
const NodeIndexPlaceholder = "{{nodeIndex}}"

internal/framework/framework.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package framework
22

33
import (
4+
"encoding/json"
5+
"log/slog"
6+
"os"
7+
"os/exec"
48
"path/filepath"
9+
"time"
510

611
"github.com/DataDog/ddtest/internal/constants"
12+
"github.com/DataDog/ddtest/internal/ext"
713
"github.com/DataDog/ddtest/internal/testoptimization"
814
)
915

@@ -14,3 +20,62 @@ type Framework interface {
1420
DiscoverTests() ([]testoptimization.Test, error)
1521
RunTests(testFiles []string, envMap map[string]string) error
1622
}
23+
24+
// cleanupDiscoveryFile removes the discovery file, ignoring "not exists" errors
25+
func cleanupDiscoveryFile(filePath string) {
26+
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
27+
slog.Warn("Warning: Failed to delete existing discovery file", "filePath", filePath, "error", err)
28+
}
29+
}
30+
31+
// applyEnvMap sets environment variables from envMap onto the command
32+
func applyEnvMap(cmd *exec.Cmd, envMap map[string]string) {
33+
if len(envMap) > 0 {
34+
cmd.Env = os.Environ()
35+
for key, value := range envMap {
36+
cmd.Env = append(cmd.Env, key+"="+value)
37+
}
38+
}
39+
}
40+
41+
// executeDiscoveryCommand runs the discovery command and logs timing
42+
func executeDiscoveryCommand(executor ext.CommandExecutor, cmd *exec.Cmd, frameworkName string) ([]byte, error) {
43+
slog.Debug("Starting test discovery...", "framework", frameworkName)
44+
startTime := time.Now()
45+
46+
output, err := executor.CombinedOutput(cmd)
47+
if err != nil {
48+
slog.Error("Failed to run test discovery", "framework", frameworkName, "output", string(output))
49+
return nil, err
50+
}
51+
52+
duration := time.Since(startTime)
53+
slog.Debug("Finished test discovery", "framework", frameworkName, "duration", duration)
54+
55+
return output, nil
56+
}
57+
58+
// parseDiscoveryFile reads and parses the test discovery JSON file
59+
func parseDiscoveryFile(filePath string) ([]testoptimization.Test, error) {
60+
file, err := os.Open(filePath)
61+
if err != nil {
62+
slog.Error("Error opening JSON file", "error", err)
63+
return nil, err
64+
}
65+
defer func() {
66+
_ = file.Close()
67+
}()
68+
69+
var tests []testoptimization.Test
70+
decoder := json.NewDecoder(file)
71+
for decoder.More() {
72+
var test testoptimization.Test
73+
if err := decoder.Decode(&test); err != nil {
74+
slog.Error("Error parsing JSON", "error", err)
75+
return nil, err
76+
}
77+
tests = append(tests, test)
78+
}
79+
80+
return tests, nil
81+
}

internal/framework/minitest.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package framework
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/DataDog/ddtest/internal/ext"
10+
"github.com/DataDog/ddtest/internal/testoptimization"
11+
)
12+
13+
type Minitest struct {
14+
executor ext.CommandExecutor
15+
}
16+
17+
func NewMinitest() *Minitest {
18+
return &Minitest{
19+
executor: &ext.DefaultCommandExecutor{},
20+
}
21+
}
22+
23+
func (m *Minitest) Name() string {
24+
return "minitest"
25+
}
26+
27+
func (m *Minitest) DiscoverTests() ([]testoptimization.Test, error) {
28+
cleanupDiscoveryFile(TestsDiscoveryFilePath)
29+
30+
cmd := m.createDiscoveryCommand()
31+
_, err := executeDiscoveryCommand(m.executor, cmd, m.Name())
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
tests, err := parseDiscoveryFile(TestsDiscoveryFilePath)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
slog.Debug("Parsed Minitest report", "tests", len(tests))
42+
return tests, nil
43+
}
44+
45+
func (m *Minitest) RunTests(testFiles []string, envMap map[string]string) error {
46+
command, args, isRails := m.getMinitestCommand()
47+
48+
// Add test files if provided
49+
if len(testFiles) > 0 {
50+
if isRails {
51+
// Rails test accepts files as command-line arguments
52+
args = append(args, testFiles...)
53+
} else {
54+
// Rake test requires TEST_FILES environment variable
55+
if envMap == nil {
56+
envMap = make(map[string]string)
57+
}
58+
envMap["TEST_FILES"] = strings.Join(testFiles, " ")
59+
}
60+
}
61+
62+
// no-dd-sa:go-security/command-injection
63+
cmd := exec.Command(command, args...)
64+
65+
applyEnvMap(cmd, envMap)
66+
67+
return m.executor.Run(cmd)
68+
}
69+
70+
// isRailsApplication determines if the current project is a Rails application
71+
func (m *Minitest) isRailsApplication() bool {
72+
// Check if rails gem is installed
73+
// no-dd-sa:go-security/command-injection
74+
cmd := exec.Command("bundle", "show", "rails")
75+
output, err := m.executor.CombinedOutput(cmd)
76+
if err != nil {
77+
slog.Debug("Not a Rails application: bundle show rails failed", "output", string(output), "error", err)
78+
return false
79+
}
80+
81+
// Verify the output is a valid filepath that exists
82+
railsPath := strings.TrimSpace(string(output))
83+
if railsPath == "" {
84+
slog.Debug("Not a Rails application: bundle show rails returned empty output")
85+
return false
86+
}
87+
if _, err := os.Stat(railsPath); err != nil {
88+
slog.Debug("Not a Rails application: rails gem path does not exist", "path", railsPath, "error", err)
89+
return false
90+
}
91+
92+
// Check if rails command works
93+
// no-dd-sa:go-security/command-injection
94+
cmd = exec.Command("bundle", "exec", "rails", "version")
95+
output, err = m.executor.CombinedOutput(cmd)
96+
if err != nil {
97+
slog.Debug("Not a Rails application: bundle exec rails version failed", "output", string(output), "error", err)
98+
return false
99+
}
100+
101+
// Verify the output starts with "Rails <version>"
102+
versionOutput := strings.TrimSpace(string(output))
103+
if !strings.HasPrefix(versionOutput, "Rails ") {
104+
slog.Debug("Not a Rails application: rails version output does not start with 'Rails '", "output", versionOutput)
105+
return false
106+
}
107+
108+
slog.Debug("Detected Rails application", "version_output", versionOutput)
109+
return true
110+
}
111+
112+
// getMinitestCommand determines whether to use rails test or rake test
113+
// Returns: command, args, isRails
114+
func (m *Minitest) getMinitestCommand() (string, []string, bool) {
115+
isRails := m.isRailsApplication()
116+
if isRails {
117+
slog.Info("Found Ruby on Rails. Using bundle exec rails test for Minitest commands")
118+
return "bundle", []string{"exec", "rails", "test"}, true
119+
}
120+
121+
slog.Info("No Ruby on Rails found. Using bundle exec rake test for Minitest commands")
122+
return "bundle", []string{"exec", "rake", "test"}, false
123+
}
124+
125+
func (m *Minitest) createDiscoveryCommand() *exec.Cmd {
126+
command, args, _ := m.getMinitestCommand()
127+
128+
// no-dd-sa:go-security/command-injection
129+
cmd := exec.Command(command, args...)
130+
cmd.Env = append(
131+
os.Environ(),
132+
"DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED=1",
133+
"DD_TEST_OPTIMIZATION_DISCOVERY_FILE="+TestsDiscoveryFilePath,
134+
)
135+
return cmd
136+
}

0 commit comments

Comments
 (0)