diff --git a/internals/testintegration/pebble_another_test.go b/internals/testintegration/pebble_another_test.go
deleted file mode 100644
index b4aee4f4..00000000
--- a/internals/testintegration/pebble_another_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build integration
-
-package testintegration_test
-
-import (
- "testing"
-
- . "github.com/canonical/pebble/internals/testintegration"
-)
-
-func TestPebbleSomethingElse(t *testing.T) {
- pebbleDir := t.TempDir()
- CreateLayer(t, pebbleDir, "001-simple-layer.yaml", DefaultLayerYAML)
- _ = PebbleRun(t, pebbleDir)
- // do something
-}
diff --git a/internals/testintegration/pebble_run_test.go b/internals/testintegration/pebble_run_test.go
deleted file mode 100644
index 168b55cc..00000000
--- a/internals/testintegration/pebble_run_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-//go:build integration
-
-package testintegration_test
-
-import (
- "fmt"
- "os"
- "testing"
-
- . "github.com/canonical/pebble/internals/testintegration"
-)
-
-func TestMain(m *testing.M) {
- if err := Setup(); err != nil {
- fmt.Println("Setup failed with error:", err)
- os.Exit(1)
- }
-
- exitVal := m.Run()
- os.Exit(exitVal)
-}
-
-func TestPebbleRunWithSimpleLayer(t *testing.T) {
- pebbleDir := t.TempDir()
-
- layerYAML := `
-services:
- demo-service:
- override: replace
- command: sleep 1000
- startup: enabled
- demo-service2:
- override: replace
- command: sleep 1000
- startup: enabled
-`[1:]
- CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML)
-
- logs := PebbleRun(t, pebbleDir)
-
- expected := []string{
- "Started daemon",
- "Service \"demo-service\" starting",
- "Service \"demo-service2\" starting",
- "Started default services with change",
- }
-
- if foundAll, notFound := AllKeywordsFoundInLogs(logs, expected); !foundAll {
- t.Errorf("Expected keywords not found in logs: %v", notFound)
- }
-}
diff --git a/internals/testintegration/utils.go b/internals/testintegration/utils.go
deleted file mode 100644
index e40cbbf6..00000000
--- a/internals/testintegration/utils.go
+++ /dev/null
@@ -1,105 +0,0 @@
-//go:build integration
-
-package testintegration
-
-import (
- "bufio"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-var DefaultLayerYAML string = `
-services:
- demo-service:
- override: replace
- command: sleep 1000
- startup: enabled
-`[1:]
-
-func Setup() error {
- cmd := exec.Command("go", "build", "./cmd/pebble")
- cmd.Dir = getRootDir()
- return cmd.Run()
-}
-
-func getRootDir() string {
- wd, _ := os.Getwd()
- return filepath.Join(wd, "../../")
-}
-
-func AllKeywordsFoundInLogs(logs []string, keywords []string) (bool, []string) {
- var notFound []string
-
- for _, keyword := range keywords {
- keywordFound := false
- for _, log := range logs {
- if strings.Contains(log, keyword) {
- keywordFound = true
- break
- }
- }
- if !keywordFound {
- notFound = append(notFound, keyword)
- }
- }
-
- return len(notFound) == 0, notFound
-}
-
-func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) {
- layersDir := filepath.Join(pebbleDir, "layers")
- err := os.MkdirAll(layersDir, 0755)
- if err != nil {
- t.Fatalf("Error creating layers directory: pipe: %v", err)
- }
-
- layerPath := filepath.Join(layersDir, layerFileName)
- err = os.WriteFile(layerPath, []byte(layerYAML), 0755)
- if err != nil {
- t.Fatalf("Error creating layers file: %v", err)
- }
-}
-
-func PebbleRun(t *testing.T, pebbleDir string) []string {
- cmd := exec.Command("./pebble", "run")
- cmd.Dir = getRootDir()
- cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir))
-
- stderrPipe, err := cmd.StderrPipe()
- if err != nil {
- t.Fatalf("Error creating stderr pipe: %v", err)
- }
-
- err = cmd.Start()
- defer cmd.Process.Kill()
- if err != nil {
- t.Fatalf("Error starting 'pebble run': %v", err)
- }
-
- var logs []string
-
- lastOutputTime := time.Now()
-
- go func() {
- scanner := bufio.NewScanner(stderrPipe)
- for scanner.Scan() {
- lastOutputTime = time.Now()
- line := scanner.Text()
- logs = append(logs, line)
- }
- }()
-
- for {
- time.Sleep(100 * time.Millisecond)
- if time.Since(lastOutputTime) > 1*time.Second {
- break
- }
- }
-
- return logs
-}
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..623fc890
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,31 @@
+# Pebble Integration Tests
+
+## Run Tests
+
+```bash
+go test -tags=integration ./tests/
+```
+
+## Developing
+
+### Clean Test Cache
+
+If you are adding tests and debugging, remember to clean test cache:
+
+```bash
+go clean -testcache && go test -v -tags=integration ./tests/
+```
+
+### Visual Studio Code Settings
+
+For the VSCode Go extention to work properly with files with build tags, add the following:
+
+```json
+{
+ "gopls": {
+ "build.buildFlags": [
+ "-tags=integration"
+ ]
+ }
+}
+```
diff --git a/tests/main_test.go b/tests/main_test.go
new file mode 100644
index 00000000..387e872a
--- /dev/null
+++ b/tests/main_test.go
@@ -0,0 +1,67 @@
+//go:build integration
+
+// Copyright (c) 2024 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package tests_test
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+
+ . "github.com/canonical/pebble/tests"
+)
+
+func TestMain(m *testing.M) {
+ goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble")
+ if err := goBuild.Run(); err != nil {
+ fmt.Println("Setup failed with error:", err)
+ os.Exit(1)
+ }
+
+ exitVal := m.Run()
+ os.Exit(exitVal)
+}
+
+func TestPebbleRunNormal(t *testing.T) {
+ pebbleDir := t.TempDir()
+
+ layerYAML := `
+services:
+ demo-service:
+ override: replace
+ command: sleep 1000
+ startup: enabled
+ demo-service2:
+ override: replace
+ command: sleep 1000
+ startup: enabled
+`[1:]
+
+ CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML)
+
+ logsCh := PebbleRun(t, pebbleDir)
+ expected := []string{
+ "Started daemon",
+ "Service \"demo-service\" starting",
+ "Service \"demo-service2\" starting",
+ "Started default services with change",
+ }
+ if err := WaitForLogs(logsCh, expected, time.Second*3); err != nil {
+ t.Errorf("Error waiting for logs: %v", err)
+ }
+}
diff --git a/tests/utils.go b/tests/utils.go
new file mode 100644
index 00000000..803d2f8a
--- /dev/null
+++ b/tests/utils.go
@@ -0,0 +1,132 @@
+//go:build integration
+
+// Copyright (c) 2024 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package tests
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) {
+ t.Helper()
+
+ layersDir := filepath.Join(pebbleDir, "layers")
+ err := os.MkdirAll(layersDir, 0755)
+ if err != nil {
+ t.Fatalf("Error creating layers directory: pipe: %v", err)
+ }
+
+ layerPath := filepath.Join(layersDir, layerFileName)
+ err = os.WriteFile(layerPath, []byte(layerYAML), 0755)
+ if err != nil {
+ t.Fatalf("Error creating layers file: %v", err)
+ }
+}
+
+func PebbleRun(t *testing.T, pebbleDir string) <-chan string {
+ t.Helper()
+
+ logsCh := make(chan string)
+
+ cmd := exec.Command("../pebble", "run")
+ cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir))
+
+ t.Cleanup(func() {
+ err := cmd.Process.Signal(os.Interrupt)
+ if err != nil {
+ t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err)
+ }
+ })
+
+ stderrPipe, err := cmd.StderrPipe()
+ if err != nil {
+ t.Fatalf("Error creating stderr pipe: %v", err)
+ }
+
+ err = cmd.Start()
+ if err != nil {
+ t.Fatalf("Error starting 'pebble run': %v", err)
+ }
+
+ go func() {
+ defer close(logsCh)
+
+ scanner := bufio.NewScanner(stderrPipe)
+ for scanner.Scan() {
+ line := scanner.Text()
+ logsCh <- line
+ }
+ }()
+
+ return logsCh
+}
+
+func WaitForLogs(logsCh <-chan string, expectedLogs []string, timeout time.Duration) error {
+ receivedLogs := make(map[string]struct{})
+ start := time.Now()
+
+ for {
+ select {
+ case log, ok := <-logsCh:
+ if !ok {
+ return errors.New("channel closed before all expected logs were received")
+ }
+
+ for _, expectedLog := range expectedLogs {
+ if _, ok := receivedLogs[expectedLog]; !ok && containsSubstring(log, expectedLog) {
+ receivedLogs[expectedLog] = struct{}{}
+ break
+ }
+ }
+
+ allLogsReceived := true
+ for _, log := range expectedLogs {
+ if _, ok := receivedLogs[log]; !ok {
+ allLogsReceived = false
+ break
+ }
+ }
+
+ if allLogsReceived {
+ return nil
+ }
+
+ default:
+ if time.Since(start) > timeout {
+ missingLogs := []string{}
+ for _, log := range expectedLogs {
+ if _, ok := receivedLogs[log]; !ok {
+ missingLogs = append(missingLogs, log)
+ }
+ }
+ return errors.New("timed out waiting for log: " + strings.Join(missingLogs, ", "))
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ }
+}
+
+func containsSubstring(s, substr string) bool {
+ return strings.Contains(s, substr)
+}