Testing graceful http server shutdown that uses channels and signals

Hi,

This is the main entrypoint of my app and I would like to test it. However, given that my Go knowledge is a few days old, I am struggling to write a test for it. Technically it boots up http server and shuts down gracefully if signal is received. How do I test this please?

Thanks

func main() {
	srv := http.NewServer()
	
	log.Print("app starting")

	shutChan := make(chan struct{})
	signChan := make(chan os.Signal, 1)
	
	signal.Notify(signChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
	
	ctx, cancel := context.WithCancel(context.Background())

	go listen(cancel, signChan)

	// This calls ListenAndServe behind the scene
	if err := srv.Start(ctx); err != nil {
		panic(err)
	}
	
	<-shutChan

	log.Print("app shutdown")
}

func listen(cancel context.CancelFunc, signChan chan os.Signal) {
	sig := <-signChan

	log.Printf("%v signal received", sig)

	cancel()
}
3 Likes

Do you mean testing with a unit test called with go test ? It will be possible only if one can generate a SIGINT or SIGTERM signal by code. I don’t know if this is possible (signChan<- Beside, one can’t test a main function with go test. To do so you would have to encapsulate your code into functions that you can then test.

For these reasons, I would test this code manually by running it and type ctrl-C.

By the way, I could not find the method Start() in the http documentation or in the source. Normally, you should call srv.Shutdown(ctx) in your listen function and the given context is typically a timeout context because Shutdown will block until all connections have returned to idle. It’s not impossible that this may block forever.

Note also that os.Interrupt is equal to syscall.SIGINT (defined in os/exec_posix.go).

2 Likes

Thanks for the input.

Yes, I meant to say I wanted to test with go test command. Currently I am testing manually as you suggested and the whole thing works fine. Since I am a learner I didn’t know how to simulate manual test in unit test.

The Start() function actually triggers ListenAndServe that comes as part of http.NewServer() at the very beginning of the code so it is a wrapper. You can see it in here which is what you answered previously.

I still need to somehow test something with cmd/api/main.go though. If you are wondering what my app looks like it is below. I am a learner so open for any suggestions for improvements.

cmd/api/main.go

package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/joho/godotenv"
	"myapp/internal/app"
	"myapp/internal/http"
)

func main() {
	srv := http.NewServer()

	log.Print("app starting")

	signChan := make(chan os.Signal, 1)

	signal.Notify(signChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

	ctx, cancel := context.WithCancel(context.Background())

	go listen(cancel, signChan)

	if err := app.New(srv).Start(ctx); err != nil {
		panic(err)
	}

	log.Print("app shutdown")
}

func listen(cancel context.CancelFunc, signChan chan os.Signal) {
	sig := <-signChan

	log.Printf("%v signal received", sig)

	cancel()
}

internal/app/api.go

package app

import (
	"context"
	"fmt"
	"log"
	"time"

	"myapp/internal/http"
)

type App struct {
	srv http.Server
}

func New(srv http.Server) App {
	return App{srv: srv}
}

func (a App) Start(ctx context.Context) error {
	shutChan := make(chan struct{})

	go shutdown(ctx, shutChan, a)

	if err := a.srv.Start(); err != nil {
		return fmt.Error("http: failed to start")
	}

	<-shutChan

	return nil
}

func shutdown(ctx context.Context, shutChan chan<- struct{}, a App) {
	<-ctx.Done()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	err := a.srv.Shutdown(ctx)
	if err != nil {
		log.Print("http: interrupted some active connections")
	} else {
		log.Print("http: served all active connections")
	}

	close(shutChan)
}

internal/http/server.go

package http

import (
	"net/http"
)

type Server struct {
	*http.Server
}

func NewServer(adr string) Server {
	return Server{
		&http.Server{Addr: adr},
	}
}

func (s Server) Start() error {
	if err := s.ListenAndServe(); err != http.ErrServerClosed {
		return err
	}

	return nil
}
2 Likes

To test something in main, you have to put the code in main into another function that you can then call from the test function.

Change this

func main() {
    do things ...
}

into

func main() {
    foo()
}

func foo() {
    do things ...
}

func TestFoo(t *testing.T) {
    foo()
    if state != expected {
        t.Error("unexpected state")
    }
}
1 Like

I will do that. Thank you.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.