Repository: lindell/multi-gitter
Pull request #18 (2020-11-15 11:23:56)

Added first tests


Patch:
diff --git a/.github/workflows/go.yaml b/.github/workflows/build.yaml
similarity index 68%
rename from .github/workflows/go.yaml
rename to .github/workflows/build.yaml
index 5483c00..a6f906a 100755
--- a/.github/workflows/go.yaml
+++ b/.github/workflows/build.yaml
@@ -1,21 +1,17 @@
-name: Go
+name: Building
 on: [push]
 jobs:
   build:
     name: Build
     runs-on: ubuntu-latest
     steps:
-      - name: Set up Go 1.14
+      - name: Set up Go 1.15
         uses: actions/setup-go@v1
         with:
-          go-version: 1.14
-        id: go
+          go-version: 1.15
 
       - name: Check out code into the Go module directory
         uses: actions/checkout@v1
 
-      - name: Test
-        run: go test ./...
-
       - name: Build
         run: go build main.go
diff --git a/.gitignore b/.gitignore
index 5636d3a..7a2448d 100755
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,5 @@ main
 tmp-docs/
 
 dist/
+
+coverage/
diff --git a/README.md b/README.md
index 2e903d6..43d2944 100755
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@
 </h1>
 
 <div align="center">
-  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3AGo"><img alt="Go build status" src="https://github.com/lindell/multi-gitter/workflows/Go/badge.svg?branch=master" /></a>
+  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3ABuilding"><img alt="Go build status" src="https://github.com/lindell/multi-gitter/workflows/Building/badge.svg?branch=master" /></a>
+  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3ATesting"><img alt="Go test status" src="https://github.com/lindell/multi-gitter/workflows/Testing/badge.svg?branch=master" /></a>
   <a href="https://goreportcard.com/report/github.com/lindell/multi-gitter"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/lindell/multi-gitter" /></a>
 </div>
 <br>
diff --git a/cmd/merge.go b/cmd/merge.go
index dc5df44..47bbd18 100644
--- a/cmd/merge.go
+++ b/cmd/merge.go
@@ -8,19 +8,21 @@ import (
 )
 
 // MergeCmd merges pull requests
-var MergeCmd = &cobra.Command{
-	Use:     "merge",
-	Short:   "Merge pull requests.",
-	Long:    "Merge pull requests with a specified branch name in an organization and with specified conditions.",
-	Args:    cobra.NoArgs,
-	PreRunE: logFlagInit,
-	RunE:    merge,
-}
+func MergeCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "merge",
+		Short:   "Merge pull requests.",
+		Long:    "Merge pull requests with a specified branch name in an organization and with specified conditions.",
+		Args:    cobra.NoArgs,
+		PreRunE: logFlagInit,
+		RunE:    merge,
+	}
+
+	cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
+	cmd.Flags().AddFlagSet(platformFlags())
+	cmd.Flags().AddFlagSet(logFlags("-"))
 
-func init() {
-	MergeCmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
-	MergeCmd.Flags().AddFlagSet(platformFlags())
-	MergeCmd.Flags().AddFlagSet(logFlags("-"))
+	return cmd
 }
 
 func merge(cmd *cobra.Command, args []string) error {
diff --git a/cmd/print.go b/cmd/print.go
index 49cf9d9..209cf58 100755
--- a/cmd/print.go
+++ b/cmd/print.go
@@ -22,21 +22,23 @@ The environment variable REPOSITORY_NAME will be set to the name of the reposito
 `
 
 // PrintCmd is the main command that runs a script for multiple repositories and print the output of each run
-var PrintCmd = &cobra.Command{
-	Use:     "print [script path]",
-	Short:   "Clones multiple repositories, run a script in that directory, and prints the output of each run.",
-	Long:    printHelp,
-	Args:    cobra.ExactArgs(1),
-	PreRunE: logFlagInit,
-	RunE:    print,
-}
+func PrintCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "print [script path]",
+		Short:   "Clones multiple repositories, run a script in that directory, and prints the output of each run.",
+		Long:    printHelp,
+		Args:    cobra.ExactArgs(1),
+		PreRunE: logFlagInit,
+		RunE:    print,
+	}
+
+	cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
+	cmd.Flags().StringP("output", "O", "-", `The file that the output of the script should be outputted to. "-" means stdout`)
+	cmd.Flags().StringP("error-output", "E", "-", `The file that the output of the script should be outputted to. "-" means stderr`)
+	cmd.Flags().AddFlagSet(platformFlags())
+	cmd.Flags().AddFlagSet(logFlags(""))
 
-func init() {
-	PrintCmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
-	PrintCmd.Flags().StringP("output", "O", "-", `The file that the output of the script should be outputted to. "-" means stdout`)
-	PrintCmd.Flags().StringP("error-output", "E", "-", `The file that the output of the script should be outputted to. "-" means stderr`)
-	PrintCmd.Flags().AddFlagSet(platformFlags())
-	PrintCmd.Flags().AddFlagSet(logFlags(""))
+	return cmd
 }
 
 func print(cmd *cobra.Command, args []string) error {
diff --git a/cmd/root.go b/cmd/root.go
index e05421c..3be0ee1 100755
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -18,18 +18,22 @@ import (
 )
 
 // RootCmd is the root command containing all subcommands
-var RootCmd = &cobra.Command{
-	Use:   "multi-gitter",
-	Short: "Multi gitter is a tool for making changes into multiple git repositories",
+func RootCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "multi-gitter",
+		Short: "Multi gitter is a tool for making changes into multiple git repositories",
+	}
+
+	cmd.AddCommand(RunCmd())
+	cmd.AddCommand(StatusCmd())
+	cmd.AddCommand(MergeCmd())
+	cmd.AddCommand(PrintCmd())
+	cmd.AddCommand(VersionCmd())
+
+	return cmd
 }
 
 func init() {
-	RootCmd.AddCommand(RunCmd)
-	RootCmd.AddCommand(StatusCmd)
-	RootCmd.AddCommand(MergeCmd)
-	RootCmd.AddCommand(PrintCmd)
-	RootCmd.AddCommand(VersionCmd)
-
 	rand.Seed(time.Now().UTC().UnixNano())
 }
 
@@ -82,7 +86,15 @@ func logFlagInit(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
+// OverrideVersionController can be set to force a specific version controller to be used
+// This is used to override the version controller with a mock, to be used during testing
+var OverrideVersionController multigitter.VersionController = nil
+
 func getVersionController(flag *flag.FlagSet) (multigitter.VersionController, error) {
+	if OverrideVersionController != nil {
+		return OverrideVersionController, nil
+	}
+
 	platform, _ := flag.GetString("platform")
 	switch platform {
 	default:
@@ -160,6 +172,10 @@ func createGitlabClient(flag *flag.FlagSet) (multigitter.VersionController, erro
 }
 
 func getToken(flag *flag.FlagSet) (string, error) {
+	if OverrideVersionController != nil {
+		return "", nil
+	}
+
 	token, _ := flag.GetString("token")
 
 	if token == "" {
diff --git a/cmd/run.go b/cmd/run.go
index bcf91c8..b314e8d 100755
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -24,28 +24,30 @@ The environment variable REPOSITORY_NAME will be set to the name of the reposito
 `
 
 // RunCmd is the main command that runs a script for multiple repositories and creates PRs with the changes made
-var RunCmd = &cobra.Command{
-	Use:     "run [script path]",
-	Short:   "Clones multiple repositories, run a script in that directory, and creates a PR with those changes.",
-	Long:    runHelp,
-	Args:    cobra.ExactArgs(1),
-	PreRunE: logFlagInit,
-	RunE:    run,
-}
+func RunCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "run [script path]",
+		Short:   "Clones multiple repositories, run a script in that directory, and creates a PR with those changes.",
+		Long:    runHelp,
+		Args:    cobra.ExactArgs(1),
+		PreRunE: logFlagInit,
+		RunE:    run,
+	}
 
-func init() {
-	RunCmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
-	RunCmd.Flags().StringP("pr-title", "t", "", "The title of the PR. Will default to the first line of the commit message if none is set.")
-	RunCmd.Flags().StringP("pr-body", "b", "", "The body of the commit message. Will default to everything but the first line of the commit message if none is set.")
-	RunCmd.Flags().StringP("commit-message", "m", "", "The commit message. Will default to title + body if none is set.")
-	RunCmd.Flags().StringSliceP("reviewers", "r", nil, "The username of the reviewers to be added on the pull request.")
-	RunCmd.Flags().IntP("max-reviewers", "M", 0, "If this value is set, reviewers will be randomized")
-	RunCmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
-	RunCmd.Flags().BoolP("dry-run", "d", false, "Run without pushing changes or creating pull requests")
-	RunCmd.Flags().StringP("author-name", "", "", "If set, this fields will be used as the name of the committer")
-	RunCmd.Flags().StringP("author-email", "", "", "If set, this fields will be used as the email of the committer")
-	RunCmd.Flags().AddFlagSet(platformFlags())
-	RunCmd.Flags().AddFlagSet(logFlags("-"))
+	cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
+	cmd.Flags().StringP("pr-title", "t", "", "The title of the PR. Will default to the first line of the commit message if none is set.")
+	cmd.Flags().StringP("pr-body", "b", "", "The body of the commit message. Will default to everything but the first line of the commit message if none is set.")
+	cmd.Flags().StringP("commit-message", "m", "", "The commit message. Will default to title + body if none is set.")
+	cmd.Flags().StringSliceP("reviewers", "r", nil, "The username of the reviewers to be added on the pull request.")
+	cmd.Flags().IntP("max-reviewers", "M", 0, "If this value is set, reviewers will be randomized")
+	cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
+	cmd.Flags().BoolP("dry-run", "d", false, "Run without pushing changes or creating pull requests")
+	cmd.Flags().StringP("author-name", "", "", "If set, this fields will be used as the name of the committer")
+	cmd.Flags().StringP("author-email", "", "", "If set, this fields will be used as the email of the committer")
+	cmd.Flags().AddFlagSet(platformFlags())
+	cmd.Flags().AddFlagSet(logFlags("-"))
+
+	return cmd
 }
 
 func run(cmd *cobra.Command, args []string) error {
diff --git a/cmd/status.go b/cmd/status.go
index 09001d8..f6ef97c 100644
--- a/cmd/status.go
+++ b/cmd/status.go
@@ -8,19 +8,21 @@ import (
 )
 
 // StatusCmd gets statuses of pull requests
-var StatusCmd = &cobra.Command{
-	Use:     "status",
-	Short:   "Get the status of pull requests.",
-	Long:    "Get the status of all pull requests with a specified branch name in an organization.",
-	Args:    cobra.NoArgs,
-	PreRunE: logFlagInit,
-	RunE:    status,
-}
+func StatusCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "status",
+		Short:   "Get the status of pull requests.",
+		Long:    "Get the status of all pull requests with a specified branch name in an organization.",
+		Args:    cobra.NoArgs,
+		PreRunE: logFlagInit,
+		RunE:    status,
+	}
+
+	cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
+	cmd.Flags().AddFlagSet(platformFlags())
+	cmd.Flags().AddFlagSet(logFlags("-"))
 
-func init() {
-	StatusCmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.")
-	StatusCmd.Flags().AddFlagSet(platformFlags())
-	StatusCmd.Flags().AddFlagSet(logFlags("-"))
+	return cmd
 }
 
 func status(cmd *cobra.Command, args []string) error {
diff --git a/cmd/version.go b/cmd/version.go
index 6bb501a..7cf35f5 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -7,12 +7,16 @@ import (
 )
 
 // VersionCmd prints the version of multi-gitter
-var VersionCmd = &cobra.Command{
-	Use:   "version",
-	Short: "Get the version of multi-gitter.",
-	Long:  "Get the version of multi-gitter.",
-	Args:  cobra.NoArgs,
-	Run:   version,
+func VersionCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "version",
+		Short: "Get the version of multi-gitter.",
+		Long:  "Get the version of multi-gitter.",
+		Args:  cobra.NoArgs,
+		Run:   version,
+	}
+
+	return cmd
 }
 
 // Version is the current version of multigitter (set by main.go)
diff --git a/docs/README.template.md b/docs/README.template.md
index 54f8038..1015e20 100755
--- a/docs/README.template.md
+++ b/docs/README.template.md
@@ -3,7 +3,8 @@
 </h1>
 
 <div align="center">
-  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3AGo"><img alt="Go build status" src="https://github.com/lindell/multi-gitter/workflows/Go/badge.svg?branch=master" /></a>
+  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3ABuilding"><img alt="Go build status" src="https://github.com/lindell/multi-gitter/workflows/Building/badge.svg?branch=master" /></a>
+  <a href="https://github.com/lindell/multi-gitter/actions?query=branch%3Amaster+workflow%3ATesting"><img alt="Go test status" src="https://github.com/lindell/multi-gitter/workflows/Testing/badge.svg?branch=master" /></a>
   <a href="https://goreportcard.com/report/github.com/lindell/multi-gitter"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/lindell/multi-gitter" /></a>
 </div>
 <br>
diff --git a/go.mod b/go.mod
index 0b6712a..315a58a 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/sirupsen/logrus v1.2.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/pflag v1.0.5
+	github.com/stretchr/testify v1.4.0
 	github.com/xanzy/go-gitlab v0.33.0
 	golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288
 )
diff --git a/go.sum b/go.sum
index 1a136ef..10b38b1 100644
--- a/go.sum
+++ b/go.sum
@@ -145,6 +145,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
diff --git a/internal/multigitter/status.go b/internal/multigitter/status.go
index 124655e..df9744b 100644
--- a/internal/multigitter/status.go
+++ b/internal/multigitter/status.go
@@ -3,6 +3,8 @@ package multigitter
 import (
 	"context"
 	"fmt"
+
+	log "github.com/sirupsen/logrus"
 )
 
 // Statuser checks the statuses of pull requests
@@ -20,7 +22,7 @@ func (s Statuser) Statuses(ctx context.Context) error {
 	}
 
 	for _, pr := range prs {
-		fmt.Printf("%s: %s\n", pr.String(), pr.Status())
+		fmt.Fprintf(log.StandardLogger().Out, "%s: %s\n", pr.String(), pr.Status())
 	}
 
 	return nil
diff --git a/main.go b/main.go
index 8c35ba6..bc4de4f 100755
--- a/main.go
+++ b/main.go
@@ -11,7 +11,7 @@ var version = "development"
 
 func main() {
 	cmd.Version = version
-	if err := cmd.RootCmd.Execute(); err != nil {
+	if err := cmd.RootCmd().Execute(); err != nil {
 		fmt.Println(err)
 		os.Exit(1)
 	}
diff --git a/tools/docs/main.go b/tools/docs/main.go
index 13caff1..7010ee1 100755
--- a/tools/docs/main.go
+++ b/tools/docs/main.go
@@ -17,8 +17,9 @@ func main() {
 		log.Fatal(err)
 	}
 
-	cmd.RootCmd.DisableAutoGenTag = true
-	err = doc.GenMarkdownTree(cmd.RootCmd, genDir)
+	rootCmd := cmd.RootCmd()
+	rootCmd.DisableAutoGenTag = true
+	err = doc.GenMarkdownTree(rootCmd, genDir)
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/tools/readme-docs/main.go b/tools/readme-docs/main.go
index 1b33ca8..7aa4b3e 100755
--- a/tools/readme-docs/main.go
+++ b/tools/readme-docs/main.go
@@ -3,6 +3,7 @@ package main
 import (
 	"bytes"
 	"errors"
+	"fmt"
 	"io/ioutil"
 	"log"
 	"path"
@@ -46,7 +47,9 @@ func main() {
 	data := templateData{}
 
 	// Main usage
-	data.MainUsage = strings.TrimSpace(cmd.RootCmd.UsageString())
+	data.MainUsage = strings.TrimSpace(cmd.RootCmd().UsageString())
+
+	subCommands := cmd.RootCmd().Commands()
 
 	// All commands
 	cmds := []struct {
@@ -55,19 +58,19 @@ func main() {
 	}{
 		{
 			imgIcon: "docs/img/fa/rabbit-fast.svg",
-			cmd:     cmd.RunCmd,
+			cmd:     commandByName(subCommands, "run"),
 		},
 		{
 			imgIcon: "docs/img/fa/code-merge.svg",
-			cmd:     cmd.MergeCmd,
+			cmd:     commandByName(subCommands, "merge"),
 		},
 		{
 			imgIcon: "docs/img/fa/tasks.svg",
-			cmd:     cmd.StatusCmd,
+			cmd:     commandByName(subCommands, "status"),
 		},
 		{
 			imgIcon: "docs/img/fa/print.svg",
-			cmd:     cmd.PrintCmd,
+			cmd:     commandByName(subCommands, "print"),
 		},
 	}
 	for _, c := range cmds {
@@ -103,6 +106,15 @@ func main() {
 	}
 }
 
+func commandByName(cmds []*cobra.Command, name string) *cobra.Command {
+	for _, command := range cmds {
+		if command.Name() == name {
+			return command
+		}
+	}
+	panic(fmt.Sprintf(`could not find command "%s"`, name))
+}
+
 var titleRegex = regexp.MustCompile("# ?Title: ([^\n]+)[\n\r]+")
 
 func readExamples() ([]exampleCategory, error) {


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100755
index 0000000..83a1019
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,34 @@
+name: Testing
+on: [push]
+jobs:
+  build:
+    name: Build
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Go 1.15
+        uses: actions/setup-go@v1
+        with:
+          go-version: 1.15
+
+      - name: Check out code into the Go module directory
+        uses: actions/checkout@v1
+
+      - name: Prepare coverage
+        run: mkdir coverage
+
+      - name: Test
+        run: go test ./... -coverpkg=$( go list ./... | grep -v /tests | grep -v /tools | paste -sd "," -) -coverprofile coverage/coverage.out
+
+      - name: Coverage convert
+        uses: jandelgado/gcov2lcov-action@v1.0.5
+        with:
+          infile: coverage/coverage.out
+          outfile: coverage/lcov.info
+
+      - name: Coveralls report
+        uses: coverallsapp/github-action@master
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Build
+        run: go build main.go
diff --git a/tests/print_test.go b/tests/print_test.go
new file mode 100644
index 0000000..ed32130
--- /dev/null
+++ b/tests/print_test.go
@@ -0,0 +1,49 @@
+package tests
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/lindell/multi-gitter/cmd"
+	"github.com/lindell/multi-gitter/tests/vcmock"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPrint(t *testing.T) {
+	vcMock := &vcmock.VersionController{}
+	cmd.OverrideVersionController = vcMock
+
+	tmpDir, err := ioutil.TempDir(os.TempDir(), "multi-git-test-run-")
+	assert.NoError(t, err)
+
+	workingDir, err := os.Getwd()
+	assert.NoError(t, err)
+
+	changeRepo := createRepo(t, "test-1", "i like apples")
+	changeRepo2 := createRepo(t, "test-2", "i like my apple")
+	noChangeRepo := createRepo(t, "test-3", "i like oranges")
+	vcMock.AddRepository(changeRepo)
+	vcMock.AddRepository(changeRepo2)
+	vcMock.AddRepository(noChangeRepo)
+
+	runLogFile := path.Join(tmpDir, "print-log.txt")
+	outFile := path.Join(tmpDir, "out.txt")
+
+	command := cmd.RootCmd()
+	command.SetArgs([]string{"print",
+		"--log-file", runLogFile,
+		"--output", outFile,
+		fmt.Sprintf(`go run %s`, path.Join(workingDir, "scripts/printer/main.go")),
+	})
+	err = command.Execute()
+	assert.NoError(t, err)
+
+	// Verify that the output was correct
+	outData, err := ioutil.ReadFile(outFile)
+	require.NoError(t, err)
+	assert.Equal(t, "i like apples\ni like my apple\ni like oranges\n", string(outData))
+}
diff --git a/tests/repo_helper_test.go b/tests/repo_helper_test.go
new file mode 100644
index 0000000..88948dd
--- /dev/null
+++ b/tests/repo_helper_test.go
@@ -0,0 +1,80 @@
+package tests
+
+import (
+	"io/ioutil"
+	"os"
+	"path"
+	"testing"
+	"time"
+
+	git "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/lindell/multi-gitter/tests/vcmock"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func createRepo(t *testing.T, name, dataInFile string) vcmock.Repository {
+	tmpDir, err := createDummyRepo(dataInFile)
+	require.NoError(t, err)
+
+	return vcmock.Repository{
+		Name: name,
+		Path: tmpDir,
+	}
+}
+
+func createDummyRepo(dataInFile string) (string, error) {
+	tmpDir, err := ioutil.TempDir(os.TempDir(), "multi-git-test-")
+	if err != nil {
+		return "", err
+	}
+
+	repo, err := git.PlainInit(tmpDir, false)
+	if err != nil {
+		return "", err
+	}
+
+	testFilePath := path.Join(tmpDir, fileName)
+
+	err = ioutil.WriteFile(testFilePath, []byte(dataInFile), 0600)
+	if err != nil {
+		return "", err
+	}
+
+	wt, err := repo.Worktree()
+	if err != nil {
+		return "", err
+	}
+
+	if _, err = wt.Add("."); err != nil {
+		return "", err
+	}
+
+	_, err = wt.Commit("First commit", &git.CommitOptions{
+		Author: &object.Signature{
+			Name:  "test",
+			Email: "test@example.com",
+			When:  time.Now(),
+		},
+	})
+	if err != nil {
+		return "", err
+	}
+
+	return tmpDir, nil
+}
+
+func changeBranch(t *testing.T, path string, branchName string) {
+	repo, err := git.PlainOpen(path)
+	assert.NoError(t, err)
+
+	wt, err := repo.Worktree()
+	assert.NoError(t, err)
+
+	err = wt.Checkout(&git.CheckoutOptions{
+		Branch: plumbing.NewBranchReferenceName(branchName),
+	})
+	assert.NoError(t, err)
+}
diff --git a/tests/scripts/changer/main.go b/tests/scripts/changer/main.go
new file mode 100644
index 0000000..a332e02
--- /dev/null
+++ b/tests/scripts/changer/main.go
@@ -0,0 +1,22 @@
+package main
+
+import (
+	"bytes"
+	"io/ioutil"
+)
+
+const fileName = "test.txt"
+
+func main() {
+	data, err := ioutil.ReadFile(fileName)
+	if err != nil {
+		panic(err)
+	}
+
+	replaced := bytes.ReplaceAll(data, []byte("apple"), []byte("banana"))
+
+	err = ioutil.WriteFile(fileName, replaced, 0600)
+	if err != nil {
+		panic(err)
+	}
+}
diff --git a/tests/scripts/printer/main.go b/tests/scripts/printer/main.go
new file mode 100644
index 0000000..f354d5e
--- /dev/null
+++ b/tests/scripts/printer/main.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+)
+
+const fileName = "test.txt"
+
+func main() {
+	data, err := ioutil.ReadFile(fileName)
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Println(string(data))
+}
diff --git a/tests/story_test.go b/tests/story_test.go
new file mode 100644
index 0000000..177a0f0
--- /dev/null
+++ b/tests/story_test.go
@@ -0,0 +1,130 @@
+package tests
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/lindell/multi-gitter/internal/domain"
+
+	"github.com/lindell/multi-gitter/cmd"
+	"github.com/lindell/multi-gitter/tests/vcmock"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const fileName = "test.txt"
+
+// TestStory tests the common usecase: run, status, merge, status
+func TestStory(t *testing.T) {
+	vcMock := &vcmock.VersionController{}
+	cmd.OverrideVersionController = vcMock
+
+	tmpDir, err := ioutil.TempDir(os.TempDir(), "multi-git-test-run-")
+	assert.NoError(t, err)
+
+	workingDir, err := os.Getwd()
+	assert.NoError(t, err)
+
+	changeRepo := createRepo(t, "should-change", "i like apples")
+	changeRepo2 := createRepo(t, "should-change-2", "i like my apple")
+	noChangeRepo := createRepo(t, "should-not-change", "i like oranges")
+	vcMock.AddRepository(changeRepo)
+	vcMock.AddRepository(changeRepo2)
+	vcMock.AddRepository(noChangeRepo)
+
+	runLogFile := path.Join(tmpDir, "run-log.txt")
+
+	command := cmd.RootCmd()
+	command.SetArgs([]string{"run",
+		"--log-file", runLogFile,
+		"--author-name", "Test Author",
+		"--author-email", "test@example.com",
+		"-B", "custom-branch-name",
+		"-m", "test",
+		fmt.Sprintf(`go run %s`, path.Join(workingDir, "scripts/changer/main.go")),
+	})
+	err = command.Execute()
+	assert.NoError(t, err)
+
+	// Verify that the data of the original branch is intact
+	data, err := ioutil.ReadFile(path.Join(changeRepo.Path, fileName))
+	assert.NoError(t, err)
+	assert.Equal(t, []byte("i like apples"), data)
+
+	// Verify that the new branch is changed
+	changeBranch(t, changeRepo.Path, "custom-branch-name")
+	data, err = ioutil.ReadFile(path.Join(changeRepo.Path, fileName))
+	assert.NoError(t, err)
+	assert.Equal(t, []byte("i like bananas"), data)
+
+	// Verify that the output was correct
+	runLogData, err := ioutil.ReadFile(runLogFile)
+	require.NoError(t, err)
+	assert.Contains(t, string(runLogData), `
+No data was changed:
+  should-not-change
+Repositories with a successful run:
+  should-change
+  should-change-2`)
+
+	//
+	// Status
+	//
+	statusLogFile := path.Join(tmpDir, "status-log.txt")
+
+	command = cmd.RootCmd()
+	command.SetArgs([]string{"status",
+		"--log-file", statusLogFile,
+		"-B", "custom-branch-name",
+	})
+	err = command.Execute()
+	assert.NoError(t, err)
+
+	// Verify that the output was correct
+	statusLogData, err := ioutil.ReadFile(statusLogFile)
+	require.NoError(t, err)
+	assert.Equal(t, "should-change/XX: Pending\nshould-change-2/XX: Pending\n", string(statusLogData))
+
+	// One of the created PRs is set to succeeded
+	vcMock.SetPRStatus("should-change", "custom-branch-name", domain.PullRequestStatusSuccess)
+
+	//
+	// Merge
+	//
+	mergeLogFile := path.Join(tmpDir, "merge-log.txt")
+
+	command = cmd.RootCmd()
+	command.SetArgs([]string{"merge",
+		"--log-file", mergeLogFile,
+		"-B", "custom-branch-name",
+	})
+	err = command.Execute()
+	assert.NoError(t, err)
+
+	// Verify that the output was correct
+	mergeLogData, err := ioutil.ReadFile(mergeLogFile)
+	require.NoError(t, err)
+	assert.Contains(t, string(mergeLogData), "Merging 1 pull requests")
+	assert.Contains(t, string(mergeLogData), "Merging repo=should-change/XX")
+
+	//
+	// After Merge Status
+	//
+	afterMergeStatusLogFile := path.Join(tmpDir, "after-merge-status-log.txt")
+
+	command = cmd.RootCmd()
+	command.SetArgs([]string{"status",
+		"--log-file", afterMergeStatusLogFile,
+		"-B", "custom-branch-name",
+	})
+	err = command.Execute()
+	assert.NoError(t, err)
+
+	// Verify that the output was correct
+	afterMergeStatusLogData, err := ioutil.ReadFile(afterMergeStatusLogFile)
+	require.NoError(t, err)
+	assert.Equal(t, "should-change/XX: Merged\nshould-change-2/XX: Pending\n", string(afterMergeStatusLogData))
+}
diff --git a/tests/vcmock/vcmock.go b/tests/vcmock/vcmock.go
new file mode 100644
index 0000000..9073b88
--- /dev/null
+++ b/tests/vcmock/vcmock.go
@@ -0,0 +1,115 @@
+// This package contains a a mock version controller (github/gitlab etc.)
+
+package vcmock
+
+import (
+	"context"
+	"errors"
+
+	"github.com/lindell/multi-gitter/internal/domain"
+)
+
+// VersionController is a mock of an version controller (Github/Gitlab/etc.)
+type VersionController struct {
+	Repositories []Repository
+	PullRequests []PullRequest
+}
+
+// GetRepositories returns mock repositories
+func (vc *VersionController) GetRepositories(ctx context.Context) ([]domain.Repository, error) {
+	ret := make([]domain.Repository, len(vc.Repositories))
+	for i := range vc.Repositories {
+		ret[i] = vc.Repositories[i]
+	}
+	return ret, nil
+}
+
+// CreatePullRequest stores a mock pull request
+func (vc *VersionController) CreatePullRequest(ctx context.Context, repo domain.Repository, newPR domain.NewPullRequest) error {
+	repository := repo.(Repository)
+
+	vc.PullRequests = append(vc.PullRequests, PullRequest{
+		PRStatus:       domain.PullRequestStatusPending,
+		Repository:     repository,
+		NewPullRequest: newPR,
+	})
+
+	return nil
+}
+
+// GetPullRequestStatuses gets mock pull request statuses
+func (vc *VersionController) GetPullRequestStatuses(ctx context.Context, branchName string) ([]domain.PullRequest, error) {
+	ret := make([]domain.PullRequest, 0, len(vc.PullRequests))
+	for _, pr := range vc.PullRequests {
+		if pr.NewPullRequest.Head == branchName {
+			ret = append(ret, pr)
+		}
+	}
+	return ret, nil
+}
+
+// MergePullRequest sets the status of a mock pull requests to merged for
+func (vc *VersionController) MergePullRequest(ctx context.Context, pr domain.PullRequest) error {
+	pullRequest := pr.(PullRequest)
+	for i := range vc.PullRequests {
+		if vc.PullRequests[i].Repository.Name == pullRequest.Repository.Name {
+			vc.PullRequests[i].PRStatus = domain.PullRequestStatusMerged
+			return nil
+		}
+	}
+	return errors.New("could not find pull request")
+}
+
+// AddRepository adds a repository to the mock
+func (vc *VersionController) AddRepository(repo Repository) {
+	vc.Repositories = append(vc.Repositories, repo)
+}
+
+// SetPRStatus sets the status of a pull request
+func (vc *VersionController) SetPRStatus(repoName string, branchName string, newStatus domain.PullRequestStatus) {
+	for i := range vc.PullRequests {
+		if vc.PullRequests[i].Repository.Name == repoName && vc.PullRequests[i].Head == branchName {
+			vc.PullRequests[i].PRStatus = newStatus
+		}
+	}
+}
+
+// PullRequest is a mock pr
+type PullRequest struct {
+	PRStatus domain.PullRequestStatus
+	Merged   bool
+
+	Repository
+	domain.NewPullRequest
+}
+
+// Status returns the pr status
+func (pr PullRequest) Status() domain.PullRequestStatus {
+	return pr.PRStatus
+}
+
+// String return a description of the pr
+func (pr PullRequest) String() string {
+	return pr.Repository.Name + "/XX"
+}
+
+// Repository is a mock repository
+type Repository struct {
+	Name string
+	Path string
+}
+
+// URL return the URL (filepath) of the repository on disk
+func (r Repository) URL(token string) string {
+	return "file://" + r.Path
+}
+
+// DefaultBranch returns "master"
+func (r Repository) DefaultBranch() string {
+	return "master"
+}
+
+// FullName returns the name of the mock repo
+func (r Repository) FullName() string {
+	return r.Name
+}