├── LICENSE ├── README.md ├── examples ├── main.go └── main_test.go ├── go.mod └── spawn.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2023 Agis Anastasopoulos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | spawn 2 | =============== 3 | 4 | Spawn makes it easy to spin up your Go server right from within its own test suite, for end-to-end testing. 5 | 6 | Usage 7 | -------------- 8 | An example usage for [this simple HTTP server](examples/main.go) can be found below. 9 | The complete runnable example is at [examples](examples/). 10 | 11 | ```go 12 | func TestMain(m *testing.M) { 13 | // start the server on localhost:8080 (we assume it accepts a `--port` argument) 14 | server := spawn.New(main, "--port", "8080") 15 | ctx, cancel := context.WithCancel(context.Background()) 16 | server.Start(ctx) 17 | 18 | // wait a bit for it to become ready 19 | time.Sleep(500 * time.Millisecond) 20 | 21 | // execute the test suite 22 | result := m.Run() 23 | 24 | // cleanly shutdown server 25 | cancel() 26 | err := server.Wait() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | os.Exit(result) 32 | } 33 | 34 | func TestServerFoo(t *testing.T) { 35 | res, _ := http.Get("http://localhost:8080/foo") 36 | defer res.Body.Close() 37 | 38 | resBody, _ := ioutil.ReadAll(res.Body) 39 | 40 | if string(resBody) != "Hello!" { 41 | t.Fatalf("expected response to be 'Hello!', got '%s'", resBody) 42 | } 43 | } 44 | 45 | // more tests using the server 46 | ``` 47 | 48 | Rationale 49 | -------------- 50 | Writing an end-to-end test for a server typically involves: 51 | 52 | 1) compiling the server code 53 | 2) spinning up the binary 54 | 3) communicating with it from the tests 55 | 4) shutting the server down 56 | 5) verify everything went OK (server was closed cleanly etc.) 57 | 58 | This package aims to simplify this process. 59 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "syscall" 12 | ) 13 | 14 | func main() { 15 | shutdown := make(chan os.Signal, 1) 16 | signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) 17 | 18 | srv := http.Server{Addr: ":8080"} 19 | 20 | http.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) { 21 | fmt.Fprintf(w, "Hello!") 22 | }) 23 | 24 | var wg sync.WaitGroup 25 | 26 | wg.Add(1) 27 | go func() { 28 | defer wg.Done() 29 | srv.ListenAndServe() 30 | fmt.Println("Shutting down...bye!") 31 | }() 32 | 33 | <-shutdown 34 | 35 | err := srv.Shutdown(context.TODO()) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | wg.Wait() 41 | } 42 | -------------------------------------------------------------------------------- /examples/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/agis/spawn" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | // start the server 17 | server := spawn.New(main) 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | server.Start(ctx) 20 | 21 | // wait a bit for it to become ready 22 | time.Sleep(500 * time.Millisecond) 23 | 24 | // execute the test suite 25 | result := m.Run() 26 | 27 | // cleanly shutdown server 28 | cancel() 29 | err := server.Wait() 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | os.Exit(result) 35 | } 36 | 37 | func TestFoo(t *testing.T) { 38 | res, err := http.Get("http://localhost:8080/foo") 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | defer res.Body.Close() 43 | 44 | resBody, err := ioutil.ReadAll(res.Body) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | if string(resBody) != "Hello!" { 50 | t.Fatalf("expected response to be 'Hello!', got '%s'", resBody) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/agis/spawn 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /spawn.go: -------------------------------------------------------------------------------- 1 | // Package spawn makes it easy to end-to-end test Go servers. The main idea is 2 | // that you spin up your server in your TestMain(), use it throughout your tests 3 | // and shut it down at the end. 4 | // 5 | // Refer to the examples directory for usage information. 6 | package spawn 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "os" 12 | "os/exec" 13 | "sync" 14 | "syscall" 15 | ) 16 | 17 | // envVar is the environment variable set on the spawned Cmd and is used 18 | // to hijack TestMain and execute main() instead. 19 | const envVar = "GOSPAWN_EXEC_MAIN" 20 | 21 | // Cmd wraps exec.Cmd and represents a binary being prepared or run. 22 | // 23 | // In the typical end-to-end testing scenario, Cmd will end up running 24 | // two times: 25 | // 26 | // 1. from TestMain when the test suite is first executed. At this 27 | // point it will spawn the already-compiled test binary (itself) again 28 | // and... 29 | // 2. from the spawned binary, inside TestMain again. But this time it will 30 | // intercept TestMain and will execute main() instead (i.e. the actual program) 31 | type Cmd struct { 32 | Cmd *exec.Cmd 33 | main func() 34 | 35 | mu sync.Mutex 36 | sigErr error 37 | } 38 | 39 | // New returns a Cmd that will either execute the passed in main() function, or the parent binary with 40 | // the given arguments. The program's main() function should be passed as f. 41 | func New(main func(), args ...string) *Cmd { 42 | cmd := exec.Command(os.Args[0], args...) 43 | cmd.Env = append(os.Environ(), envVar+"=1") 44 | cmd.Stdout = os.Stdout 45 | cmd.Stderr = os.Stderr 46 | 47 | return &Cmd{ 48 | main: main, 49 | Cmd: cmd, 50 | } 51 | } 52 | 53 | // Start starts c until it terminates or ctx is cancelled. It does not wait 54 | // for it to complete. When ctx is cancelled a SIGINT is sent to c. 55 | // 56 | // The Wait method will return the exit code and release associated resources 57 | // once the command exits. 58 | func (c *Cmd) Start(ctx context.Context) error { 59 | if os.Getenv(envVar) == "1" { 60 | c.main() 61 | 62 | // we don't want to continue executing in TestMain() 63 | os.Exit(0) 64 | } 65 | 66 | err := c.Cmd.Start() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | go func() { 72 | <-ctx.Done() 73 | c.mu.Lock() 74 | defer c.mu.Unlock() 75 | c.sigErr = c.Cmd.Process.Signal(syscall.SIGINT) 76 | }() 77 | 78 | return nil 79 | } 80 | 81 | // Wait waits for the command to exit and waits for any copying to stdin or 82 | // copying from stdout or stderr to complete. 83 | // 84 | // The command must have been started by Start. 85 | // 86 | // The returned error is nil if the command runs, has no problems copying 87 | // stdin, stdout, and stderr, exits with a zero exit status and any signals 88 | // to the command were delivered successfully. 89 | // 90 | // Wait releases any resources associated with the command. 91 | func (c *Cmd) Wait() error { 92 | err := c.Cmd.Wait() 93 | if err != nil { 94 | return err 95 | } 96 | 97 | c.mu.Lock() 98 | defer c.mu.Unlock() 99 | if c.sigErr != nil { 100 | return errors.New(c.sigErr.Error()) 101 | } 102 | 103 | return nil 104 | } 105 | --------------------------------------------------------------------------------