httpsteps

package module
v0.2.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 18, 2024 License: MIT Imports: 23 Imported by: 1

README

Cucumber HTTP steps for Go

Build Status Coverage Status GoDevDoc Time Tracker Code lines Comments

This module implements HTTP-related step definitions for github.com/cucumber/godog.

Steps

Local Client

Local and remote services can be tested with client request configuration and response expectations.

Client does not attempt to limit concurrency of multiple godog scenarios, it is expected that either the service is capable to process multiple scenarios simultaneously, or scenarios are synchronized explicitly.

Request Setup
When I request HTTP endpoint with method "GET" and URI "/get-something?foo=bar"

In request configuration steps you can specify name of the service to apply configuration. If service name is omitted, default service (with URL passed to NewLocalClient) is used:

  • request HTTP endpoint - default service,
  • request "some-service" HTTP endpoint - service named some-service.

Named services have to be explicitly added with their base URLs before running tests.

When I request "some-service" HTTP endpoint with method "GET" and URI "/get-something?foo=bar"

An additional header can be supplied. For multiple headers, call step multiple times.

And I request HTTP endpoint with header "X-Foo: bar"
And I request "some-service" HTTP endpoint with header "X-Foo: bar"

Or use table of values.

And I request "some-service" HTTP endpoint with headers
  | X-Foo | foo |
  | X-Bar | 123 |

An additional cookie can be supplied. For multiple cookies, call step multiple times.

And I request HTTP endpoint with cookie "name: value"
And I request "some-service" HTTP endpoint with cookie "name: value"

Or use table of values.

And I request "some-service" HTTP endpoint with cookies
  | cfoo | foo |
  | cbar | 123 |

Optionally request body can be configured. If body is a valid JSON5 payload, it will be converted to JSON before use. Otherwise, body is used as is.

And I request HTTP endpoint with body
"""
[
  // JSON5 comments are allowed.
  {"some":"json"}
]
"""

Request body can be provided from file.

And I request HTTP endpoint with body from file
"""
path/to/file.json5
"""

Request body can be defined as form data.

And I request "some-service" HTTP endpoint with urlencoded form data
  | ffoo | abc |
  | fbar | 123 |
  | fbar | 456 |

By default, redirects are not followed. This behavior can be changed.

And I follow redirects from HTTP endpoint
And I follow redirects from "some-service" HTTP endpoint

If endpoint is capable of handling duplicated requests, you can check it for idempotency. This would send multiple requests simultaneously and check

  • if all responses are similar or (all successful like GET)
  • if responses can be grouped into exactly ONE response of a kind and OTHER responses of another kind (one successful, other failed like with POST).

Number of requests can be configured with Local.ConcurrencyLevel, default value is 10.

And I concurrently request idempotent HTTP endpoint

Or for a named service.

And I concurrently request idempotent "some-service" HTTP endpoint

In case of flakyness or async operation you can use retries to improve resiliency. Retry limit should be configured before any response expectations. Only first response expectation is used as a condition for retry, so checking status code might be a good idea. Retry limit can be a number of retries (e.g. 1 time or 5 times) or maximum elapsed duration in time.Duration format (e.g. 5s or 10m).

By default, exponential backoff is used, but it is possible to implement your own strategy with (*LocalClient).RetryBackOff.

    And I retry "some-service" HTTP request up to 120s
    # And I retry "some-service" HTTP request up to 5 times
    # And I retry "some-service" HTTP request up to 1 time

Response Expectations

Response expectation has to be configured with at least one step about status, response body or other responses body ( idempotency mode).

If response body is a valid JSON5 payload, it is converted to JSON before use.

JSON bodies are compared with assertjson which allows ignoring differences when expected value is set to "<ignore-diff>".

And I should have response with body
"""
[
  {"some":"json","time":"<ignore-diff>"}
]
"""
And I should have response with body from file
"""
path/to/file.json
"""

Instead of ignoring particular fields, you can match only specific fields.

And I should have response with body, that matches JSON
"""
[
  {"some":"json"}
]
"""
And I should have response with body, that matches JSON from file
"""
path/to/file.json
"""

Another flavour of JSON matching is to match only specific fields with JSON Path notation.

    # Body can be asserted with JSON path expressions table,
    # where first column is JSON path expression and second column is expected JSON value.
    # It is also possible to capture/assert variable values (see "$dyn").
    And I should have "some-service" response with body, that matches JSON paths
    | $.*.some | ["json"] |
    | $[0].dyn | "$dyn"   |

Status can be defined with either phrase or numeric code.

```gherkin
Then I should have response with status "OK"
Then I should have response with status "204"

And I should have other responses with status "Not Found"

In an idempotent mode you can check other responses.

And I should have other responses with body
"""
{"status":"failed"}
"""
And I should have other responses with body from file
"""
path/to/file.json
"""

Optionally response headers can be asserted.

Then I should have response with header "Content-Type: application/json"

And I should have other responses with header "Content-Type: text/plain"
And I should have other responses with header "X-Header: abc"

Header can be checked using a table.

And I should have "some-service" response with headers
  | Content-Type | application/json |
  | X-Baz        | abc              |

You can set expectations for named service by adding service name before response or other responses:

  • have response - default,
  • have other responses - default,
  • have "some-service" response - service named some-service,
  • have "some-service" other responses - service named some-service.
External Server

External Server mock creates an HTTP server for each of registered services and allows control of expected requests and responses with gherkin steps.

It is useful describe behavior of HTTP endpoints that are called by the app during test (e.g. 3rd party APIs).

Please note, due to centralized nature of these mocks they can not be used concurrently by different scenarios. If multiple scenarios configure a shared service, they will be locked in a sync sequence. It is safe to use concurrent scenarios.

In simple case you can define expected URL and response.

Given "some-service" receives "GET" request "/get-something?foo=bar"

And "some-service" responds with status "OK" and body
"""
{"key":"value"}
"""

Or request with body.

And "another-service" receives "POST" request "/post-something" with body
"""
// Could be a JSON5 too.
{"foo":"bar"}
"""

Request with body from a file.

And "another-service" receives "POST" request "/post-something" with body from file
"""
_testdata/sample.json
"""

Request can expect to have a header.

And "some-service" request includes header "X-Foo: bar"

By default, each configured request is expected to be received 1 time. This can be changed to a different number.

And "some-service" request is received 1234 times

Or to be unlimited.

And "some-service" request is received several times

By default, requests are expected in same sequential order as they are defined. If there is no stable order you can have an async expectation. Async requests are expected in any order.

And "some-service" request is async

Response may have a header.

And "some-service" response includes header "X-Bar: foo"

Response must have a status.

And "some-service" responds with status "OK"

Response may also have a body.

And "some-service" responds with status "OK" and body
"""
{"key":"value"}
"""
And "another-service" responds with status "200" and body from file
"""
_testdata/sample.json5
"""
Dynamic Variables

When data is not known in advance, but can be inferred from previous steps, you can use dynamic variables.

See also steps to manage variables: github.com/godogx/vars.

Here is an example where value from response of one step is used in request of another step.

  Scenario: Creating user and making an order
    When I request HTTP endpoint with method "POST" and URI "/user"

    And I request HTTP endpoint with body
    """json
    {"name": "John Doe"}
    """

    # Undefined variable infers its value from the actual data on first encounter.
    Then I should have response with body
    """json5
    {
      // Capturing dynamic user id as $user_id variable.
     "id":"$user_id",
     "name": "John Doe",
     // Ignoring other dynamic values.
     "created_at":"<ignore-diff>","updated_at": "<ignore-diff>"
    }
    """

    # Creating an order for that user with $user_id.
    When I request HTTP endpoint with method "POST" and URI "/order"

    And I request HTTP endpoint with body
    """json5
    {
      // Replacing with the value of a variable captured previously.
      "user_id": "$user_id",
      "item_name": "Watermelon"
    }
    """
    # Variable interpolation works also with body from file.

    Then I should have response with body
    """json5
    {
     "id":"<ignore-diff>",
     "created_at":"<ignore-diff>","updated_at": "<ignore-diff>",
     "user_id":"$user_id"
     "prefixed_user_id": "static_prefix::$user_id"
    }
    """

    # Instead of ignoring fields with "<ignore-diff>" you can also match against a reduced JSON.
    # All fields that are not present in the expected JSON are ignored.
    # Step below is equivalent to the previous one.
    And I should have response with body, that matches JSON
    """json5
    {
     "user_id":"$user_id"
     "prefixed_user_id": "static_prefix::$user_id"
    }
    """

Example Feature

Feature: Example

  Scenario: Successful GET Request
    Given "template-service" receives "GET" request "/template/hello"

    And "template-service" responds with status "OK" and body
    """
    Hello, %s!
    """

    When I request HTTP endpoint with method "GET" and URI "/?name=Jane"

    Then I should have response with status "OK"

    And I should have response with body
    """
    Hello, Jane!
    """

Documentation

Overview

Package httpsteps provides HTTP-related step definitions for github.com/cucumber/godog.

Feature: Example

 Scenario: Successful GET Request
   Given "template-service" receives "GET" request "/template/hello"

   And "template-service" responds with status "OK" and body
   """
   Hello, %s!
   """

   When I request HTTP endpoint with method "GET" and URI "/?name=Jane"

   Then I should have response with status "OK"

   And I should have response with body
   """
   Hello, Jane!
   """

Index

Examples

Constants

View Source
const (
	// Default is the name of default service.
	Default = "default"
)

Variables

This section is empty.

Functions

func DefaultExposeHTTPDetails added in v0.2.15

func DefaultExposeHTTPDetails(ctx context.Context, d httpmock.HTTPValue) (context.Context, error)

DefaultExposeHTTPDetails instruments context with godog.Attachment items of HTTP transaction.

func LoadBody deprecated added in v0.2.8

func LoadBody(body []byte, vars *shared.Vars) ([]byte, error)

LoadBody loads body from bytes and replaces vars in it.

Deprecated: use github.com/godogx/vars.(*Steps).Replace.

func LoadBodyFromFile deprecated added in v0.2.8

func LoadBodyFromFile(filePath string, vars *shared.Vars) ([]byte, error)

LoadBodyFromFile loads body from file and replaces vars in it.

Deprecated: use github.com/godogx/vars.(*Steps).ReplaceFile.

Types

type ExternalServer

type ExternalServer struct {

	// Deprecated: use VS.JSONComparer.Vars to seed initial values if necessary.
	Vars *shared.Vars

	VS *vars.Steps
	// contains filtered or unexported fields
}

ExternalServer is a collection of step-driven HTTP servers to serve requests of application with mocked data.

Please use NewExternalServer() to create an instance.

func NewExternalServer added in v0.2.0

func NewExternalServer() *ExternalServer

NewExternalServer creates an ExternalServer.

func (*ExternalServer) Add

func (e *ExternalServer) Add(service string, options ...func(mock *httpmock.Server)) string

Add starts a mocked server for a named service and returns url.

func (*ExternalServer) GetMock

func (e *ExternalServer) GetMock(service string) *httpmock.Server

GetMock exposes mock of external service for configuration.

func (*ExternalServer) RegisterSteps

func (e *ExternalServer) RegisterSteps(s *godog.ScenarioContext)

RegisterSteps adds steps to godog scenario context to serve outgoing requests with mocked data.

In simple case you can define expected URL and response.

Given "some-service" receives "GET" request "/get-something?foo=bar"

And "some-service" responds with status "OK" and body
"""
{"key":"value"}
"""

Or request with body.

And "another-service" receives "POST" request "/post-something" with body
"""
// Could be a JSON5 too.
{"foo":"bar"}
"""

Request with body from a file.

And "another-service" receives "POST" request "/post-something" with body from file
"""
_testdata/sample.json
"""

Request can expect to have a header.

And "some-service" request includes header "X-Foo: bar"

By default, each configured request is expected to be received 1 time. This can be changed to a different number.

And "some-service" request is received 1234 times

Or to be unlimited.

And "some-service" request is received several times

By default, requests are expected in same sequential order as they are defined. If there is no stable order you can have an async expectation. Async requests are expected in any order.

And "some-service" request is async

Response may have a header.

And "some-service" response includes header "X-Bar: foo"

Response must have a status.

And "some-service" responds with status "OK"

Response may also have a body.

And "some-service" responds with status "OK" and body
"""
{"key":"value"}
"""

Response body can also be defined in file.

And "another-service" responds with status "200" and body from file
"""
_testdata/sample.json5
"""

type HTTPValue added in v0.2.15

type HTTPValue struct {
	Sequence int
	Request  *http.Request
	Response *http.Response
	Error    error
}

HTTPValue grants access to a HTTP request and response.

type LocalClient

type LocalClient struct {

	// Deprecated: use VS.JSONComparer.Vars.
	Vars *shared.Vars

	VS           *vars.Steps
	RetryBackOff func(ctx context.Context, maxElapsedTime time.Duration) (context.Context, httpmock.RetryBackOff)

	// ExposeHTTPDetails enables godog.Attachment for request and response data.
	// Has DefaultExposeHTTPDetails by default.
	ExposeHTTPDetails func(ctx context.Context, d httpmock.HTTPValue) (context.Context, error)
	// contains filtered or unexported fields
}

LocalClient is step-driven HTTP service for application local HTTP service.

func NewLocalClient

func NewLocalClient(defaultBaseURL string, options ...func(*httpmock.Client)) *LocalClient

NewLocalClient creates an instance of step-driven HTTP service.

Example
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"

	"github.com/cucumber/godog"
	"github.com/godogx/httpsteps"
)

func main() {
	external := httpsteps.NewExternalServer()
	templateService := external.Add("template-service")

	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		req, _ := http.NewRequest(http.MethodGet, templateService+"/template/hello", nil)
		resp, _ := http.DefaultTransport.RoundTrip(req)
		tpl, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()

		_, _ = w.Write([]byte(fmt.Sprintf(string(tpl), r.URL.Query().Get("name"))))
	})

	srv := httptest.NewServer(h)
	defer srv.Close()

	local := httpsteps.NewLocalClient(srv.URL)

	suite := godog.TestSuite{
		ScenarioInitializer: func(s *godog.ScenarioContext) {
			local.RegisterSteps(s)
			external.RegisterSteps(s)
		},
		Options: &godog.Options{
			Format: "pretty",
			Strict: true,
			Paths:  []string{"_testdata/Example.feature"},
			Output: io.Discard,
		},
	}

	if suite.Run() != 0 {
		fmt.Println("test failed")
	} else {
		fmt.Println("test passed")
	}

}
Output:

test passed

func (*LocalClient) AddService

func (l *LocalClient) AddService(name, baseURL string)

AddService registers a URL for named service.

func (*LocalClient) RegisterSteps

func (l *LocalClient) RegisterSteps(s *godog.ScenarioContext)

RegisterSteps adds HTTP server steps to godog scenario context.

Request Setup

Request configuration needs at least HTTP method and URI.

When I request HTTP endpoint with method "GET" and URI "/get-something?foo=bar"

Configuration can be bound to a specific named service. This service must be registered before. service name should be added before `HTTP endpoint`.

And I request "some-service" HTTP endpoint with header "X-Foo: bar"

An additional header can be supplied. For multiple headers, call step multiple times.

And I request HTTP endpoint with header "X-Foo: bar"

An additional cookie can be supplied. For multiple cookie, call step multiple times.

And I request HTTP endpoint with cookie "name: value"

Optionally request body can be configured. If body is a valid JSON5 payload, it will be converted to JSON before use. Otherwise, body is used as is.

And I request HTTP endpoint with body
"""
[
 // JSON5 comments are allowed.
 {"some":"json"}
]
"""

Request body can be provided from file.

And I request HTTP endpoint with body from file
"""
path/to/file.json5
"""

If endpoint is capable of handling duplicated requests, you can check it for idempotency. This would send multiple requests simultaneously and check

  • if all responses are similar or (all successful like GET),
  • if responses can be grouped into exactly ONE response of a kind and OTHER responses of another kind (one successful, other failed like with POST).

Number of requests can be configured with `LocalClient.ConcurrencyLevel`, default value is 10.

And I concurrently request idempotent HTTP endpoint

Response Expectations

Response expectation has to be configured with at least one step about status, response body or other responses body (idempotency mode).

If response body is a valid JSON5 payload, it is converted to JSON before use.

JSON bodies are compared with https://github.com/swaggest/assertjson which allows ignoring differences when expected value is set to `"<ignore-diff>"`.

And I should have response with body
"""
[
 {"some":"json","time":"<ignore-diff>"}
]
"""

Response body can be provided from file.

And I should have response with body from file
"""
path/to/file.json
"""

Status can be defined with either phrase or numeric code. Also, you can set response header expectations.

Then I should have response with status "OK"
And I should have response with header "Content-Type: application/json"
And I should have response with header "X-Header: abc"

In an idempotent mode you can set expectations for statuses of other responses.

Then I should have response with status "204"

And I should have other responses with status "Not Found"
And I should have other responses with header "Content-Type: application/json"

And for bodies of other responses.

And I should have other responses with body
"""
{"status":"failed"}
"""

Which can be defined as files.

And I should have other responses with body from file
"""
path/to/file.json
"""

More information at https://github.com/godogx/httpsteps/#local-client.

func (*LocalClient) Service

func (l *LocalClient) Service(ctx context.Context, service string) (*httpmock.Client, context.Context, error)

Service returns named service client or fails for undefined service.

func (*LocalClient) SetBaseURL added in v0.2.3

func (l *LocalClient) SetBaseURL(baseURL string, service string) error

SetBaseURL sets the base URL for the client.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL