Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,17 @@ In CI‑node mode, DDTest also fans out across local CPUs on that node and furth

### Settings (flags and environment variables)

| CLI flag | Environment variable | Default | What it does |
| ------------------- | --------------------------------------------- | ---------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | `ruby` | Language/platform (currently supported values: `ruby`). |
| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`, `minitest`). |
| `--min-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM` | vCPU count | Minimum workers to use for the split. |
| `--max-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM` | vCPU count | Maximum workers to use for the split. |
| | `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. |
| `--worker-env` | `DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV` | `""` | Template env vars per local worker (e.g., isolate DBs): `--worker-env "DATABASE_NAME_TEST=app_test{{nodeIndex}}"`. |
| `--command` | `DD_TEST_OPTIMIZATION_RUNNER_COMMAND` | `""` | Override the default test command used by the framework. When provided, takes precedence over auto-detection (e.g., `--command "bundle exec custom-rspec"`). |
| `--tests-location` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION` | `""` | Custom glob pattern to discover test files (e.g., `--tests-location "custom/spec/**/*_spec.rb"`). Defaults to `spec/**/*_spec.rb` for RSpec, `test/**/*_test.rb` for Minitest. |
| CLI flag | Environment variable | Default | What it does |
| ------------------- | --------------------------------------------- | ---------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | `ruby` | Language/platform (currently supported values: `ruby`). |
| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`, `minitest`). |
| `--min-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM` | vCPU count | Minimum workers to use for the split. |
| `--max-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM` | vCPU count | Maximum workers to use for the split. |
| | `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. |
| `--worker-env` | `DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV` | `""` | Template env vars per local worker (e.g., isolate DBs): `--worker-env "DATABASE_NAME_TEST=app_test{{nodeIndex}}"`. |
| `--command` | `DD_TEST_OPTIMIZATION_RUNNER_COMMAND` | `""` | Override the default test command used by the framework. When provided, takes precedence over auto-detection (e.g., `--command "bundle exec custom-rspec"`). |
| `--tests-location` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION` | `""` | Custom glob pattern to discover test files (e.g., `--tests-location "custom/spec/**/*_spec.rb"`). Defaults to `spec/**/*_spec.rb` for RSpec, `test/**/*_test.rb` for Minitest. |
| `--runtime-tags` | `DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS` | `""` | JSON string to override runtime tags used to fetch skippable tests. Useful for local development on a different OS than CI (e.g., `--runtime-tags '{"os.platform":"linux","runtime.version":"3.2.0"}'`). |

#### Note about the `--command` flag

Expand Down Expand Up @@ -463,6 +464,59 @@ Rake::TestTask.new(:test) do |test|
end
```

## Running tests locally with CI's skippable tests

When using Test Impact Analysis, skippable tests are scoped by runtime environment (OS, architecture, language version). This means tests skipped in your Linux CI won't automatically be skipped on your macOS development machine because the runtime tags differ.

The `--runtime-tags` option lets you override your local runtime tags to match your CI environment, enabling you to benefit from Test Impact Analysis locally without re-running your entire test suite.

### How to use

1. **Find your CI's runtime tags in Datadog**

Open any test run from your CI in [Datadog Test Optimization](https://app.datadoghq.com/ci/test-runs). In the test details panel, look for the `os` and `runtime` sections:

![Runtime tags in Datadog](docs/images/runtime-tags-datadog.png)

Note the following tags:

- `os.architecture` (e.g., `x86_64`)
- `os.platform` (e.g., `linux`)
- `os.version` (e.g., `6.8.0-aws`)
- `runtime.name` (e.g., `ruby`)
- `runtime.version` (e.g., `3.3.0`)

2. **Create the runtime tags JSON**

Build a JSON object with these tags:

```json
{
"os.architecture": "x86_64",
"os.platform": "linux",
"os.version": "6.8.0-aws",
"runtime.name": "ruby",
"runtime.version": "3.3.0"
}
```

3. **Run ddtest with the override**

Pass the JSON as a single-line string:

```bash
ddtest run --runtime-tags '{"os.architecture":"x86_64","os.platform":"linux","os.version":"6.8.0-aws","runtime.name":"ruby","runtime.version":"3.3.0"}'
```

Or use an environment variable (useful for shell aliases):

```bash
export DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS='{"os.architecture":"x86_64","os.platform":"linux","os.version":"6.8.0-aws","runtime.name":"ruby","runtime.version":"3.3.0"}'
ddtest run
```

> **Note:** Test Impact Analysis works on committed changes. Make sure to commit your changes before running ddtest to see accurate skippable tests.

## Development

### Prerequisites
Expand Down
Binary file added docs/images/runtime-tags-datadog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions internal/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func init() {
rootCmd.PersistentFlags().String("worker-env", "", "Worker environment configuration")
rootCmd.PersistentFlags().String("command", "", "Test command that ddtest should wrap")
rootCmd.PersistentFlags().String("tests-location", "", "Glob pattern used to discover test files")
rootCmd.PersistentFlags().String("runtime-tags", "", "JSON string to override runtime tags (e.g. '{\"os.platform\":\"linux\",\"runtime.version\":\"3.2.0\"}')")
if err := viper.BindPFlag("platform", rootCmd.PersistentFlags().Lookup("platform")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding platform flag: %v\n", err)
os.Exit(1)
Expand Down Expand Up @@ -87,6 +88,10 @@ func init() {
fmt.Fprintf(os.Stderr, "Error binding tests-location flag: %v\n", err)
os.Exit(1)
}
if err := viper.BindPFlag("runtime_tags", rootCmd.PersistentFlags().Lookup("runtime-tags")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding runtime-tags flag: %v\n", err)
os.Exit(1)
}

rootCmd.AddCommand(planCmd)
rootCmd.AddCommand(runCmd)
Expand Down
10 changes: 0 additions & 10 deletions internal/framework/framework.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"log/slog"
"maps"
"os"
"path/filepath"
"time"
Expand Down Expand Up @@ -89,15 +88,6 @@ func globTestFiles(pattern string) ([]string, error) {
return matches, nil
}

// mergeEnvMaps merges base env vars with overrides.
// Values in overrides take precedence over base values.
func mergeEnvMaps(base, overrides map[string]string) map[string]string {
result := make(map[string]string)
maps.Copy(result, base)
maps.Copy(result, overrides)
return result
}

// BaseDiscoveryEnv returns environment variables required for all test discovery processes.
// These env vars ensure the test framework runs in discovery mode without requiring
// actual Datadog credentials or agent connectivity.
Expand Down
61 changes: 0 additions & 61 deletions internal/framework/framework_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,64 +28,3 @@ func TestBaseDiscoveryEnv(t *testing.T) {
t.Errorf("expected %d env vars, got %d", len(expectedVars), len(env))
}
}

func TestMergeEnvMaps(t *testing.T) {
t.Run("merges two maps", func(t *testing.T) {
platform := map[string]string{"A": "1", "B": "2"}
additional := map[string]string{"C": "3", "D": "4"}

result := mergeEnvMaps(platform, additional)

if len(result) != 4 {
t.Errorf("expected 4 keys, got %d", len(result))
}
if result["A"] != "1" || result["B"] != "2" || result["C"] != "3" || result["D"] != "4" {
t.Errorf("unexpected result: %v", result)
}
})

t.Run("additional overrides platform", func(t *testing.T) {
platform := map[string]string{"A": "platform", "B": "2"}
additional := map[string]string{"A": "additional", "C": "3"}

result := mergeEnvMaps(platform, additional)

if result["A"] != "additional" {
t.Errorf("expected additional to override platform, got %q", result["A"])
}
if result["B"] != "2" {
t.Errorf("expected B to be preserved, got %q", result["B"])
}
if result["C"] != "3" {
t.Errorf("expected C to be present, got %q", result["C"])
}
})

t.Run("handles nil maps", func(t *testing.T) {
result := mergeEnvMaps(nil, nil)
if result == nil {
t.Error("expected non-nil result")
}
if len(result) != 0 {
t.Errorf("expected empty map, got %v", result)
}
})

t.Run("handles nil platform", func(t *testing.T) {
additional := map[string]string{"A": "1"}
result := mergeEnvMaps(nil, additional)

if result["A"] != "1" {
t.Errorf("expected A=1, got %q", result["A"])
}
})

t.Run("handles nil additional", func(t *testing.T) {
platform := map[string]string{"A": "1"}
result := mergeEnvMaps(platform, nil)

if result["A"] != "1" {
t.Errorf("expected A=1, got %q", result["A"])
}
})
}
9 changes: 7 additions & 2 deletions internal/framework/minitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package framework
import (
"context"
"log/slog"
"maps"
"os"
"strings"

Expand Down Expand Up @@ -51,7 +52,9 @@ func (m *Minitest) DiscoverTests(ctx context.Context) ([]testoptimization.Test,
slog.Debug("Using test discovery pattern", "pattern", pattern)

// Merge env maps: platform env -> base discovery env
envMap := mergeEnvMaps(m.platformEnv, BaseDiscoveryEnv())
envMap := make(map[string]string)
maps.Copy(envMap, m.platformEnv)
maps.Copy(envMap, BaseDiscoveryEnv())

if isRails {
args = append(args, pattern)
Expand Down Expand Up @@ -109,7 +112,9 @@ func (m *Minitest) RunTests(ctx context.Context, testFiles []string, envMap map[
}
}

mergedEnv := mergeEnvMaps(m.platformEnv, envMap)
mergedEnv := make(map[string]string)
maps.Copy(mergedEnv, m.platformEnv)
maps.Copy(mergedEnv, envMap)
return m.executor.Run(ctx, command, args, mergedEnv)
}

Expand Down
9 changes: 7 additions & 2 deletions internal/framework/rspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package framework
import (
"context"
"log/slog"
"maps"
"os"

"github.com/DataDog/ddtest/internal/ext"
Expand Down Expand Up @@ -51,7 +52,9 @@ func (r *RSpec) DiscoverTests(ctx context.Context) ([]testoptimization.Test, err
args = append(args, "--pattern", pattern)

// Merge env maps: platform env -> base discovery env
envMap := mergeEnvMaps(r.platformEnv, BaseDiscoveryEnv())
envMap := make(map[string]string)
maps.Copy(envMap, r.platformEnv)
maps.Copy(envMap, BaseDiscoveryEnv())

slog.Info("Using test discovery pattern", "pattern", pattern)
slog.Info("Discovering tests with command", "command", name, "args", args)
Expand Down Expand Up @@ -92,7 +95,9 @@ func (r *RSpec) RunTests(ctx context.Context, testFiles []string, envMap map[str
slog.Info("Running tests with command", "command", command, "args", args)
args = append(args, testFiles...)

mergedEnv := mergeEnvMaps(r.platformEnv, envMap)
mergedEnv := make(map[string]string)
maps.Copy(mergedEnv, r.platformEnv)
maps.Copy(mergedEnv, envMap)
return r.executor.Run(ctx, command, args, mergedEnv)
}

Expand Down
17 changes: 16 additions & 1 deletion internal/runner/dd_test_optimization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"fmt"
"log/slog"
"maps"
"time"

"github.com/DataDog/ddtest/internal/settings"
"github.com/DataDog/ddtest/internal/testoptimization"
"golang.org/x/sync/errgroup"
)
Expand All @@ -16,12 +18,25 @@ func (tr *TestRunner) PrepareTestOptimization(ctx context.Context) error {
return fmt.Errorf("failed to detect platform: %w", err)
}

// Get platform-detected tags first
tags, err := detectedPlatform.CreateTagsMap()
if err != nil {
return fmt.Errorf("failed to create platform tags: %w", err)
}

slog.Info("Preparing test optimization data", "runtimeTags", tags, "platform", detectedPlatform.Name())
// Check if runtime tags override is provided and merge onto detected tags
overrideTags, err := settings.GetRuntimeTagsMap()
if err != nil {
return fmt.Errorf("failed to parse runtime tags override: %w", err)
}

if overrideTags != nil {
// Merge override tags onto detected tags (override values take precedence)
maps.Copy(tags, overrideTags)
slog.Info("Merged runtime tags override from --runtime-tags", "overrideTags", overrideTags, "mergedTags", tags)
} else {
slog.Info("Preparing test optimization data", "runtimeTags", tags, "platform", detectedPlatform.Name())
}

// Detect framework once to avoid duplicate work
framework, err := detectedPlatform.DetectFramework()
Expand Down
Loading
Loading