diff --git a/Makefile b/Makefile index 7d5a764..4b82dd4 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ -.PHONY: test +.PHONY: test deps -# runs all necessary tests test: + @echo "running all tests" @sh -c 'if [ ! -z "$(go fmt ./...)" ]; then exit 1; else echo "go fmt OK"; fi' @sh -c 'if [ ! -z "$(golint ./...)" ]; then exit 1; else echo "golint OK"; fi' go vet ./... go test ./... go run cmd/godog/main.go -f progress -# updates dependencies deps: + @echo "updating all dependencies" go get -u github.com/cucumber/gherkin-go go get -u github.com/shiena/ansicolor + diff --git a/examples/db/Makefile b/examples/db/Makefile new file mode 100644 index 0000000..4d5aa00 --- /dev/null +++ b/examples/db/Makefile @@ -0,0 +1,8 @@ +.PHONY: test + +test: + mysql -u root -e 'DROP DATABASE IF EXISTS `godog_test`' + mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `godog_test`' + mysql -u root godog_test < db.sql + godog users.feature + diff --git a/examples/db/README.md b/examples/db/README.md new file mode 100644 index 0000000..f6fb538 --- /dev/null +++ b/examples/db/README.md @@ -0,0 +1,17 @@ +# An example of API with DB + +The following example demonstrates steps how we describe and test our API with DB using **godog**. +To start with, see [API example](https://github.com/DATA-DOG/godog/tree/master/examples/api) before. +We have extended it to be used with database. + +The interesting point is, that we have **txdb.go** which has an implementation of custom sql.driver +to allow execute every and each scenario within a **transaction**. After it completes, transaction +is rolled back so the state could be clean for the next scenario. + +To run **users.feature** you need MySQL installed on your system with an anonymous root password. +Then run: + + make test + +The json comparisom function should be improved and we should also have placeholders for primary +keys when comparing a json result. diff --git a/examples/db/api.go b/examples/db/api.go new file mode 100644 index 0000000..ba24b32 --- /dev/null +++ b/examples/db/api.go @@ -0,0 +1,95 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + + _ "github.com/go-sql-driver/mysql" +) + +type server struct { + db *sql.DB +} + +type User struct { + ID int64 `json:"-"` + Username string `json:"username"` + Email string `json:"-"` +} + +func (s *server) users(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + fail(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + users := make([]*User, 0) + rows, err := s.db.Query("SELECT id, email, username FROM users") + defer rows.Close() + switch err { + case sql.ErrNoRows: + case nil: + for rows.Next() { + user := &User{} + if err := rows.Scan(&user.ID, &user.Email, &user.Username); err != nil { + fail(w, fmt.Sprintf("failed to scan an user: %s", err), http.StatusInternalServerError) + return + } + users = append(users, user) + } + default: + fail(w, fmt.Sprintf("failed to fetch users: %s", err), http.StatusInternalServerError) + return + } + + data := struct { + Users []*User `json:"users"` + }{Users: users} + + ok(w, data) +} + +func main() { + db, err := sql.Open("mysql", "root@/godog") + if err != nil { + panic(err) + } + s := &server{db: db} + http.HandleFunc("/users", s.users) + http.ListenAndServe(":8080", nil) +} + +// fail writes a json response with error msg and status header +func fail(w http.ResponseWriter, msg string, status int) { + w.Header().Set("Content-Type", "application/json") + + data := struct { + Error string `json:"error"` + }{Error: msg} + + resp, _ := json.Marshal(data) + w.WriteHeader(status) + + fmt.Fprintf(w, string(resp)) +} + +// ok writes data to response with 200 status +func ok(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + + if s, ok := data.(string); ok { + fmt.Fprintf(w, s) + return + } + + resp, err := json.Marshal(data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fail(w, "oops something evil has happened", 500) + return + } + + fmt.Fprintf(w, string(resp)) +} diff --git a/examples/db/api_test.go b/examples/db/api_test.go new file mode 100644 index 0000000..441af17 --- /dev/null +++ b/examples/db/api_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + + "github.com/DATA-DOG/godog" + "github.com/cucumber/gherkin-go" +) + +func init() { + // we register an sql driver txdb + Register("mysql", "root@/godog_test") +} + +type apiFeature struct { + server + resp *httptest.ResponseRecorder +} + +func (a *apiFeature) resetResponse(interface{}) { + a.resp = httptest.NewRecorder() + if a.db != nil { + a.db.Close() + } + db, err := sql.Open("txdb", "") + if err != nil { + panic(err) + } + a.db = db +} + +func (a *apiFeature) iSendrequestTo(method, endpoint string) (err error) { + req, err := http.NewRequest(method, endpoint, nil) + if err != nil { + return + } + + // handle panic + defer func() { + switch t := recover().(type) { + case string: + err = fmt.Errorf(t) + case error: + err = t + } + }() + + switch endpoint { + case "/users": + a.users(a.resp, req) + default: + err = fmt.Errorf("unknown endpoint: %s", endpoint) + } + return +} + +func (a *apiFeature) theResponseCodeShouldBe(code int) error { + if code != a.resp.Code { + if a.resp.Code >= 400 { + return fmt.Errorf("expected response code to be: %d, but actual is: %d, response message: %s", code, a.resp.Code, string(a.resp.Body.Bytes())) + } + return fmt.Errorf("expected response code to be: %d, but actual is: %d", code, a.resp.Code) + } + return nil +} + +func (a *apiFeature) theResponseShouldMatchJSON(body *gherkin.DocString) (err error) { + var expected, actual []byte + var data interface{} + if err = json.Unmarshal([]byte(body.Content), &data); err != nil { + return + } + if expected, err = json.Marshal(data); err != nil { + return + } + actual = a.resp.Body.Bytes() + if string(actual) != string(expected) { + err = fmt.Errorf("expected json %s, does not match actual: %s", string(expected), string(actual)) + } + return +} + +func (a *apiFeature) thereAreUsers(users *gherkin.DataTable) error { + var fields []string + var marks []string + head := users.Rows[0].Cells + for _, cell := range head { + fields = append(fields, cell.Value) + marks = append(marks, "?") + } + + stmt, err := a.db.Prepare("INSERT INTO users (" + strings.Join(fields, ", ") + ") VALUES(" + strings.Join(marks, ", ") + ")") + if err != nil { + return err + } + for i := 1; i < len(users.Rows); i++ { + var vals []interface{} + for n, cell := range users.Rows[i].Cells { + switch head[n].Value { + case "username": + vals = append(vals, cell.Value) + case "email": + vals = append(vals, cell.Value) + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + if _, err = stmt.Exec(vals...); err != nil { + return err + } + } + return nil +} + +func featureContext(s godog.Suite) { + api := &apiFeature{} + + s.BeforeScenario(api.resetResponse) + + s.Step(`^I send "(GET|POST|PUT|DELETE)" request to "([^"]*)"$`, api.iSendrequestTo) + s.Step(`^the response code should be (\d+)$`, api.theResponseCodeShouldBe) + s.Step(`^the response should match json:$`, api.theResponseShouldMatchJSON) + s.Step(`^there are users:$`, api.thereAreUsers) +} diff --git a/examples/db/db.sql b/examples/db/db.sql new file mode 100644 index 0000000..9ae3f74 --- /dev/null +++ b/examples/db/db.sql @@ -0,0 +1,7 @@ +CREATE TABLE users ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + `username` VARCHAR(32) NOT NULL, + `email` VARCHAR(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `uniq_email` (`email`) +) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB; diff --git a/examples/db/txdb.go b/examples/db/txdb.go new file mode 100644 index 0000000..b01234c --- /dev/null +++ b/examples/db/txdb.go @@ -0,0 +1,146 @@ +package main + +import ( + "database/sql" + "database/sql/driver" + "io" + "sync" +) + +// Register a txdb sql driver which can be used to open +// a single transaction based database connection pool +func Register(drv, dsn string) { + sql.Register("txdb", &txDriver{dsn: dsn, drv: drv}) +} + +// txDriver is an sql driver which runs on single transaction +// when the Close is called, transaction is rolled back +type txDriver struct { + sync.Mutex + tx *sql.Tx + + drv string + dsn string + db *sql.DB +} + +func (d *txDriver) Open(dsn string) (driver.Conn, error) { + // first open a real database connection + var err error + if d.db == nil { + db, err := sql.Open(d.drv, d.dsn) + if err != nil { + return d, err + } + d.db = db + } + if d.tx == nil { + d.tx, err = d.db.Begin() + } + return d, err +} + +func (d *txDriver) Close() error { + err := d.tx.Rollback() + d.tx = nil + return err +} + +func (d *txDriver) Begin() (driver.Tx, error) { + return d, nil +} + +func (d *txDriver) Commit() error { + return nil +} + +func (d *txDriver) Rollback() error { + return nil +} + +func (d *txDriver) Prepare(query string) (driver.Stmt, error) { + return &stmt{drv: d, query: query}, nil +} + +type stmt struct { + query string + drv *txDriver +} + +func (s *stmt) Exec(args []driver.Value) (driver.Result, error) { + s.drv.Lock() + defer s.drv.Unlock() + + st, err := s.drv.tx.Prepare(s.query) + if err != nil { + return nil, err + } + defer st.Close() + var iargs []interface{} + for _, arg := range args { + iargs = append(iargs, arg) + } + return st.Exec(iargs...) +} + +func (s *stmt) NumInput() int { + return -1 +} + +func (s *stmt) Close() error { + return nil +} + +func (s *stmt) Query(args []driver.Value) (driver.Rows, error) { + s.drv.Lock() + defer s.drv.Unlock() + + st, err := s.drv.tx.Prepare(s.query) + if err != nil { + return nil, err + } + // do not close the statement here, Rows need it + var iargs []interface{} + for _, arg := range args { + iargs = append(iargs, arg) + } + rs, err := st.Query(iargs...) + return &rows{rs: rs}, err +} + +type rows struct { + err error + rs *sql.Rows +} + +func (r *rows) Columns() (cols []string) { + cols, r.err = r.rs.Columns() + return +} + +func (r *rows) Next(dest []driver.Value) error { + if r.err != nil { + return r.err + } + if r.rs.Err() != nil { + return r.rs.Err() + } + if !r.rs.Next() { + return io.EOF + } + values := make([]interface{}, len(dest)) + for i := range values { + values[i] = new(interface{}) + } + if err := r.rs.Scan(values...); err != nil { + return err + } + for i, val := range values { + dest[i] = *(val.(*interface{})) + } + return r.rs.Err() +} + +func (r *rows) Close() error { + return r.rs.Close() +} diff --git a/examples/db/users.feature b/examples/db/users.feature new file mode 100644 index 0000000..71ec12d --- /dev/null +++ b/examples/db/users.feature @@ -0,0 +1,52 @@ +Feature: users + In order to use users api + As an API user + I need to be able to manage users + + Scenario: should get empty users + When I send "GET" request to "/users" + Then the response code should be 200 + And the response should match json: + """ + { + "users": [] + } + """ + + Scenario: should get users + Given there are users: + | username | email | + | john | john.doe@mail.com | + | jane | jane.doe@mail.com | + When I send "GET" request to "/users" + Then the response code should be 200 + And the response should match json: + """ + { + "users": [ + { + "username": "john" + }, + { + "username": "jane" + } + ] + } + """ + + Scenario: should get users when there is only one + Given there are users: + | username | email | + | gopher | gopher@mail.com | + When I send "GET" request to "/users" + Then the response code should be 200 + And the response should match json: + """ + { + "users": [ + { + "username": "gopher" + } + ] + } + """