Skip to content

Commit a8ffc31

Browse files
authored
Add test command to run any unit test (#1092)
This commit adds a `test` command that allows the student to run the tests for an exercise without knowing the track-specific test command. This makes it easier for the student to get started. For debugging and education purposes, we print the command that is used to run the tests.
1 parent edee207 commit a8ffc31

File tree

7 files changed

+604
-6
lines changed

7 files changed

+604
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ The exercism CLI follows [semantic versioning](http://semver.org/).
55
----------------
66

77
## Next Release
8+
* [#1092](https://github.com/exercism/cli/pull/1092) Add `exercism test` command to run the unit tests for nearly any track (inspired by [universal-test-runner](https://github.com/xavdid/universal-test-runner)) - [@xavdid]
89
* **Your contribution here**
910

1011
## v3.1.0 (2022-10-04)
@@ -489,5 +490,6 @@ All changes by [@msgehard]
489490
[@sfairchild]: https://github.com/sfairchild
490491
[@simonjefford]: https://github.com/simonjefford
491492
[@srt32]: https://github.com/srt32
493+
[@xavdid]: https://github.com/xavdid
492494
[@williandrade]: https://github.com/williandrade
493495
[@zabawaba99]: https://github.com/zabawaba99

cmd/cmd_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ const cfgHomeKey = "EXERCISM_CONFIG_HOME"
2626
// test, call the command by calling Execute on the App.
2727
//
2828
// Example:
29-
// cmdTest := &CommandTest{
30-
// Cmd: myCmd,
31-
// InitFn: initMyCmd,
32-
// Args: []string{"fakeapp", "mycommand", "arg1", "--flag", "value"},
33-
// MockInteractiveResponse: "first-input\nsecond\n",
34-
// }
29+
//
30+
// cmdTest := &CommandTest{
31+
// Cmd: myCmd,
32+
// InitFn: initMyCmd,
33+
// Args: []string{"fakeapp", "mycommand", "arg1", "--flag", "value"},
34+
// MockInteractiveResponse: "first-input\nsecond\n",
35+
// }
36+
//
3537
// cmdTest.Setup(t)
3638
// defer cmdTest.Teardown(t)
3739
// ...

cmd/test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
10+
"github.com/exercism/cli/workspace"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var testCmd = &cobra.Command{
15+
Use: "test",
16+
Aliases: []string{"t"},
17+
Short: "Run the exercise's tests.",
18+
Long: `Run the exercise's tests.
19+
20+
Run this command in an exercise's root directory.`,
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
return runTest(args)
23+
},
24+
}
25+
26+
func runTest(args []string) error {
27+
track, err := getTrack()
28+
if err != nil {
29+
return err
30+
}
31+
32+
testConf, ok := workspace.TestConfigurations[track]
33+
34+
if !ok {
35+
return fmt.Errorf("the \"%s\" track does not yet support running tests using the Exercism CLI. Please see HELP.md for testing instructions", track)
36+
}
37+
38+
command, err := testConf.GetTestCommand()
39+
if err != nil {
40+
return err
41+
}
42+
cmdParts := strings.Split(command, " ")
43+
44+
// pass args/flags to this command down to the test handler
45+
if len(args) > 0 {
46+
cmdParts = append(cmdParts, args...)
47+
}
48+
49+
fmt.Printf("Running tests via `%s`\n\n", strings.Join(cmdParts, " "))
50+
exerciseTestCmd := exec.Command(cmdParts[0], cmdParts[1:]...)
51+
52+
// pipe output directly out, preserving any color
53+
exerciseTestCmd.Stdout = os.Stdout
54+
exerciseTestCmd.Stderr = os.Stderr
55+
56+
err = exerciseTestCmd.Run()
57+
if err != nil {
58+
// unclear what other errors would pop up here, but it pays to be defensive
59+
if exitErr, ok := err.(*exec.ExitError); ok {
60+
exitCode := exitErr.ExitCode()
61+
// if subcommand returned a non-zero exit code, exit with the same
62+
os.Exit(exitCode)
63+
} else {
64+
log.Fatalf("Failed to get error from failed subcommand: %v", err)
65+
}
66+
}
67+
return nil
68+
}
69+
70+
func getTrack() (string, error) {
71+
metadata, err := workspace.NewExerciseMetadata(".")
72+
if err != nil {
73+
return "", err
74+
}
75+
if metadata.Track == "" {
76+
return "", fmt.Errorf("no track found in exercise metadata")
77+
}
78+
79+
return metadata.Track, nil
80+
}
81+
82+
func init() {
83+
RootCmd.AddCommand(testCmd)
84+
}

workspace/exercise_config.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package workspace
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io/ioutil"
7+
"path/filepath"
8+
)
9+
10+
const configFilename = "config.json"
11+
12+
var configFilepath = filepath.Join(ignoreSubdir, configFilename)
13+
14+
// ExerciseConfig contains exercise metadata.
15+
// Note: we only use a subset of its fields
16+
type ExerciseConfig struct {
17+
Files struct {
18+
Solution []string `json:"solution"`
19+
Test []string `json:"test"`
20+
} `json:"files"`
21+
}
22+
23+
// NewExerciseConfig reads exercise metadata from a file in the given directory.
24+
func NewExerciseConfig(dir string) (*ExerciseConfig, error) {
25+
b, err := ioutil.ReadFile(filepath.Join(dir, configFilepath))
26+
if err != nil {
27+
return nil, err
28+
}
29+
var config ExerciseConfig
30+
if err := json.Unmarshal(b, &config); err != nil {
31+
return nil, err
32+
}
33+
34+
return &config, nil
35+
}
36+
37+
// GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any
38+
func (c *ExerciseConfig) GetSolutionFiles() ([]string, error) {
39+
result := c.Files.Solution
40+
if result == nil {
41+
// solution file(s) key was missing in config json, which is an error when calling this fuction
42+
return []string{}, errors.New("no `files.solution` key in your `config.json`. Was it removed by mistake?")
43+
}
44+
45+
return result, nil
46+
}
47+
48+
// GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any
49+
func (c *ExerciseConfig) GetTestFiles() ([]string, error) {
50+
result := c.Files.Test
51+
if result == nil {
52+
// test file(s) key was missing in config json, which is an error when calling this fuction
53+
return []string{}, errors.New("no `files.test` key in your `config.json`. Was it removed by mistake?")
54+
}
55+
56+
return result, nil
57+
}

workspace/exercise_config_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package workspace
2+
3+
import (
4+
"io/ioutil"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestExerciseConfig(t *testing.T) {
14+
dir, err := ioutil.TempDir("", "exercise_config")
15+
assert.NoError(t, err)
16+
defer os.RemoveAll(dir)
17+
18+
err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm)
19+
assert.NoError(t, err)
20+
21+
f, err := os.Create(filepath.Join(dir, ".exercism", "config.json"))
22+
assert.NoError(t, err)
23+
defer f.Close()
24+
25+
_, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "solution": ["lasagna.rb"], "test": ["lasagna_test.rb"], "exemplar": [".meta/exemplar.rb"] } } `)
26+
assert.NoError(t, err)
27+
28+
ec, err := NewExerciseConfig(dir)
29+
assert.NoError(t, err)
30+
31+
assert.Equal(t, ec.Files.Solution, []string{"lasagna.rb"})
32+
solutionFiles, err := ec.GetSolutionFiles()
33+
assert.NoError(t, err)
34+
assert.Equal(t, solutionFiles, []string{"lasagna.rb"})
35+
36+
assert.Equal(t, ec.Files.Test, []string{"lasagna_test.rb"})
37+
testFiles, err := ec.GetTestFiles()
38+
assert.NoError(t, err)
39+
assert.Equal(t, testFiles, []string{"lasagna_test.rb"})
40+
}
41+
42+
func TestExerciseConfigNoTestKey(t *testing.T) {
43+
dir, err := ioutil.TempDir("", "exercise_config")
44+
assert.NoError(t, err)
45+
defer os.RemoveAll(dir)
46+
47+
err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm)
48+
assert.NoError(t, err)
49+
50+
f, err := os.Create(filepath.Join(dir, ".exercism", "config.json"))
51+
assert.NoError(t, err)
52+
defer f.Close()
53+
54+
_, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "exemplar": [".meta/exemplar.rb"] } } `)
55+
assert.NoError(t, err)
56+
57+
ec, err := NewExerciseConfig(dir)
58+
assert.NoError(t, err)
59+
60+
_, err = ec.GetSolutionFiles()
61+
assert.Error(t, err, "no `files.solution` key in your `config.json`")
62+
_, err = ec.GetTestFiles()
63+
assert.Error(t, err, "no `files.test` key in your `config.json`")
64+
}
65+
66+
func TestMissingExerciseConfig(t *testing.T) {
67+
dir, err := ioutil.TempDir("", "exercise_config")
68+
assert.NoError(t, err)
69+
defer os.RemoveAll(dir)
70+
71+
_, err = NewExerciseConfig(dir)
72+
assert.Error(t, err)
73+
// any assertions about this error message have to work across all platforms, so be vague
74+
// unix: ".exercism/config.json: no such file or directory"
75+
// windows: "open .exercism\config.json: The system cannot find the path specified."
76+
assert.Contains(t, err.Error(), filepath.Join(".exercism", "config.json:"))
77+
}
78+
79+
func TestInvalidExerciseConfig(t *testing.T) {
80+
dir, err := ioutil.TempDir("", "exercise_config")
81+
assert.NoError(t, err)
82+
defer os.RemoveAll(dir)
83+
84+
err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm)
85+
assert.NoError(t, err)
86+
87+
f, err := os.Create(filepath.Join(dir, ".exercism", "config.json"))
88+
assert.NoError(t, err)
89+
defer f.Close()
90+
91+
// invalid JSON
92+
_, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarr `)
93+
assert.NoError(t, err)
94+
95+
_, err = NewExerciseConfig(dir)
96+
assert.Error(t, err)
97+
assert.True(t, strings.Contains(err.Error(), "unexpected end of JSON input"))
98+
}

0 commit comments

Comments
 (0)