Skip to content
Open
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,54 @@ $ dockerfile-json --expand-build-args=false --jsonpath=..BaseName Dockerfile
"build"
"${APP_BASE}"
```

## Testing

### Running tests

```bash
go test -tags=dfrunsecurity -v ./...
```

### Adding integration tests

Each directory in `testdata/*` is the main directory of a test scenario.
Each scenario can have sub-scenarios (subdirectories), but they all share one Containerfile.

```text
testdata/
my-scenario/
Containerfile # required, shared by sub-scenarios
expected.json # required but can be auto-generated
args.txt # optional args for main scenario
sub-scenario-1/
expected.json
args.txt # args for sub-scenario
sub-scenario-2/
expected.json
args.txt
```

Generate expected outputs:

```bash
# Generate for all test cases
UPDATE_TESTDATA=1 go test -tags=dfrunsecurity

# Or generate for specific test case
UPDATE_TESTDATA=1 go test -tags=dfrunsecurity -run=TestIntegration/my-scenario/variant-1
```

In this mode (`UPDATE_TESTDATA=1`), tests overwrite the `expected.json` files instead
of comparing them with the actual output. To compare against the existing data, run
the tests without the `UPDATE_TESTDATA` variable.

### Updating test expectations

The update mode is also useful when making changes to existing behavior. Re-generate
the output files and verify that your changes achieved what you intended:

```bash
UPDATE_TESTDATA=1 go test -tags=dfrunsecurity
git diff testdata/ # Review changes
```
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.23
toolchain go1.23.1

require (
github.com/google/go-cmp v0.7.0
github.com/moby/buildkit v0.19.0
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/moby/buildkit v0.19.0 h1:w9G1p7sArvCGNkpWstAqJfRQTXBKukMyMK1bsah1HNo=
Expand Down
209 changes: 209 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package main_test

import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
)

// TestIntegration discovers and runs all integration test cases in testdata/
func TestIntegration(t *testing.T) {
binaryPath := buildBinary(t)
defer os.Remove(binaryPath)

testCases := discoverTestCases(t)
if len(testCases) == 0 {
t.Skip("No test cases found in testdata/")
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
runTestCase(t, binaryPath, tc)
})
}
}

// testCase represents a single integration test case
type testCase struct {
name string
dir string
inputFile string
expectedFile string
argsFile string
}

// buildBinary builds the dockerfile-json binary and returns its path
func buildBinary(t *testing.T) string {
t.Helper()

binaryPath := filepath.Join(t.TempDir(), "dockerfile-json")
cmd := exec.Command("go", "build", "-tags=dfrunsecurity", "-o", binaryPath, ".")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to build binary: %v\nOutput: %s", err, output)
}

return binaryPath
}

// discoverTestCases scans testdata/ for test case directories.
// A scenario directory must contain a Containerfile and is always a test case.
// All subdirectories are also test cases, using the parent's Containerfile.
func discoverTestCases(t *testing.T) []testCase {
t.Helper()

testdataDir := "testdata"
entries, err := os.ReadDir(testdataDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("Failed to read testdata directory: %v", err)
}

var cases []testCase
for _, entry := range entries {
if !entry.IsDir() {
continue
}

scenarioDir := filepath.Join(testdataDir, entry.Name())
containerfile := filepath.Join(scenarioDir, "Containerfile")

// Scenario directory must have a Containerfile
if _, err := os.Stat(containerfile); os.IsNotExist(err) {
continue
}

// Main scenario
cases = append(cases, testCase{
name: entry.Name(),
dir: scenarioDir,
inputFile: containerfile,
expectedFile: filepath.Join(scenarioDir, "expected.json"),
argsFile: filepath.Join(scenarioDir, "args.txt"),
})

// Sub-scenarios
subEntries, err := os.ReadDir(scenarioDir)
if err != nil {
t.Fatalf("Failed to read scenario directory %s: %v", scenarioDir, err)
}

for _, sub := range subEntries {
if !sub.IsDir() {
continue
}

subDir := filepath.Join(scenarioDir, sub.Name())
cases = append(cases, testCase{
name: entry.Name() + "/" + sub.Name(),
dir: subDir,
inputFile: containerfile,
expectedFile: filepath.Join(subDir, "expected.json"),
argsFile: filepath.Join(subDir, "args.txt"),
})
}
}

return cases
}

// runTestCase executes a single test case
func runTestCase(t *testing.T, binaryPath string, tc testCase) {
t.Helper()

args := readArgs(t, tc.argsFile)
args = append(args, tc.inputFile)

// Execute the binary
cmd := exec.Command(binaryPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to execute binary: %v\nOutput: %s", err, output)
}

// Update mode: write output to expected.json
// Use UPDATE_TESTDATA=1 or UPDATE_TESTDATA=true to update golden files
updateTestdata := os.Getenv("UPDATE_TESTDATA")
if updateTestdata == "1" || strings.EqualFold(updateTestdata, "true") {
if err := updateGoldenFile(tc.expectedFile, output); err != nil {
t.Fatalf("Failed to update golden file: %v", err)
}
t.Logf("Updated %s", tc.expectedFile)
return
}

// Comparison mode: compare with expected.json
expected, err := os.ReadFile(tc.expectedFile)
if err != nil {
t.Fatalf("Failed to read expected file %s: %v\nRun with UPDATE_TESTDATA=1 to generate it", tc.expectedFile, err)
}
if err := compareJSON(t, expected, output); err != nil {
t.Fatalf("Output mismatch:\n%v", err)
}
}

// readArgs reads arguments from args.txt file (one per line)
func readArgs(t *testing.T, argsFile string) []string {
t.Helper()

data, err := os.ReadFile(argsFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("Failed to read args file: %v", err)
}

lines := strings.Split(string(data), "\n")
var args []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
args = append(args, line)
}
}

return args
}

// updateGoldenFile writes the output to the golden file with pretty formatting.
// It formats the JSON without unmarshaling to preserve deterministic key ordering
// from the binary's struct field order.
func updateGoldenFile(path string, data []byte) error {
var buf bytes.Buffer
if err := json.Indent(&buf, data, "", " "); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0644)
}

// compareJSON compares two JSON byte slices by pretty-printing them
// and comparing as strings. This provides more readable diffs.
func compareJSON(t *testing.T, expected, actual []byte) error {
t.Helper()

var expectedBuf bytes.Buffer
if err := json.Indent(&expectedBuf, expected, "", " "); err != nil {
return fmt.Errorf("failed to format expected JSON: %w", err)
}

var actualBuf bytes.Buffer
if err := json.Indent(&actualBuf, actual, "", " "); err != nil {
return fmt.Errorf("failed to format actual JSON: %w", err)
}

if diff := cmp.Diff(expectedBuf.String(), actualBuf.String()); diff != "" {
return fmt.Errorf("JSON mismatch (-expected +actual):\n%s", diff)
}

return nil
}
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func init() {
flag.StringVar(&config.JSONPathString, "jsonpath", config.JSONPathString, "select parts of the output using JSONPath (https://goessner.net/articles/JsonPath)")
flag.BoolVar(&config.JSONPathRaw, "jsonpath-raw", config.JSONPathRaw, "when using JSONPath, output raw strings, not JSON values")
flag.Var(&config.BuildArgs, "build-arg", config.BuildArgs.Help())
}

func parseFlags() {
flag.Parse()

if config.Quiet {
Expand Down Expand Up @@ -80,6 +83,8 @@ func buildArgEnvExpander() dockerfile.SingleWordExpander {
}

func main() {
parseFlags()

var dockerfiles []*dockerfile.Dockerfile
for _, path := range flag.Args() {
dockerfile, err := dockerfile.Parse(path)
Expand Down
5 changes: 5 additions & 0 deletions testdata/basic-dockerfile/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM alpine:3.19

RUN echo "hello world"

CMD ["/bin/sh"]
53 changes: 53 additions & 0 deletions testdata/basic-dockerfile/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"MetaArgs": null,
"Stages": [
{
"Name": "",
"OrigCmd": "FROM",
"BaseName": "alpine:3.19",
"Platform": "",
"Comment": "",
"SourceCode": "FROM alpine:3.19",
"Location": [
{
"Start": {
"Line": 1,
"Character": 0
},
"End": {
"Line": 1,
"Character": 0
}
}
],
"From": {
"Image": "alpine:3.19"
},
"Commands": [
{
"CmdLine": [
"echo \"hello world\""
],
"Files": null,
"FlagsUsed": [],
"Mounts": [],
"Name": "RUN",
"NetworkMode": "default",
"PrependShell": true,
"Security": "sandbox"
},
{
"CmdLine": [
"/bin/sh"
],
"Files": null,
"Mounts": null,
"Name": "CMD",
"NetworkMode": "",
"PrependShell": false,
"Security": ""
}
]
}
]
}
27 changes: 27 additions & 0 deletions vendor/github.com/google/go-cmp/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading