Testen einer REST-Schnittstelle mit REST Assured

07.12.2013

Testen einer REST-Schnittstelle mit REST Assured

In dem folgenden Artikel wird beschrieben, wie ein REST-Service, der mit Spring MVC implementiert wurde, mittels REST Assured getestet werden kann. Dabei werden die Requests POST, PUT, GET und DELETE verwendet. Bei den REST Assured Tests werden beim Überprüfen der JSON-Responses zwei Möglichkeiten vorgestellt. Als erstes ein eigener Hamcrest-Matcher und als zweites Möglichkeit die Überprüfung mittels JSONPath. Als Testbeispiel dient ein einfacher REST-Service, den ich in dem Artikel Spring MVC: Ein minimales REST-Beispiel beschrieben habe.

Maven und REST Assured

Damit REST Assured und JSONPath in den JUnit-Tests zur Verfügung steht, muss die Datei pom.xml um folgende Dependencies erweitert werden.

<dependency>
	<groupId>com.jayway.restassured</groupId>
	<artifactId>rest-assured</artifactId>
	<version>2.0.1</version>
	<scope>test</scope>
</dependency>

<dependency>
	<groupId>com.jayway.restassured</groupId>
	<artifactId>json-path</artifactId>
	<version>2.0.1</version>
	<scope>test</scope>
</dependency>

Allgemeines zu REST Assured Tests

Prinzipiell sind REST Assured Tests immer nach dem gleichen AAA-Muster aufgebaut.

given().
	ARRANGE
when().
    ACT
then().
	ASSERT

Mit given() wird ein Testszenario beschrieben (ARRANGE). Mit when() wird beschrieben, welche Operationen ausgeführt werden soll (ACT). Und mit then() wird überprüft, ob das Ergebnis den Annahmen entspricht (ASSERT).

Hinweis: Seit REST Assured 2.x wird diese Notation verwendet. In der vorangegangenen Version wurde noch expect() anstatt des Aufrufs then() verwendet.

Setup

Für die, in den folgenden Abschnitten beschriebenen Tests, werden folgende Variablen und Imports benötigt.

package org.hameister.spring;

import static com.jayway.restassured.RestAssured.given;
import static com.jayway.restassured.path.json.JsonPath.from;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isIn;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;

import java.util.List;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

import com.jayway.restassured.RestAssured;
import com.jayway.restassured.builder.RequestSpecBuilder;
import com.jayway.restassured.builder.ResponseSpecBuilder;
import com.jayway.restassured.http.ContentType;
import com.jayway.restassured.response.Response;

public class CustomerControllerRESTassuredTest {

	private static final String CUSTOMER_ID_DOES_NOT_EXIST = "doesNotExist";

	private static final String ANY_NAME = "anyName";

	private static final String INVALID_ARGUMENT = "invalidArgument";

	RequestSpecBuilder requestSpecBuilder = new RequestSpecBuilder();

	@BeforeClass
	public static void setupConnection() {
		RestAssured.baseURI = "http://localhost";
		RestAssured.port = 2001;
		RestAssured.basePath = "/spring/customers";
	}


	@Before
	public void setup() throws Exception {
		requestSpecBuilder.setContentType(ContentType.JSON).addHeader("Accept", ContentType.JSON.getAcceptHeader());
	}

In der Funktion setupConnection wird REST Assured mitgeteilt, wo der Server zu erreichen ist.

Die Funktion setup definiert einen RequestSpecBuilder, der festlegt, dass in den folgenden Tests bei einem Request immer JSON verwendet wird. Außerdem wird der Accept-Header gesetzt. Mit dem RequestSpecBuilder spart man sich, dass diese Dinge bei jedem Test erneut definiert werden müssen. Wenn man beim Schreiben von Tests merkt, dass Requests immer gleich aufgebaut sind, dann bietet es sicht an, diese in den RequestSpecBuilder auszulagern. Für die Antworten (Response) wird ein ResponseSpecBuilder zur Verfügung gestellt.

Tests mit REST Assured

Bei den folgenden Tests mit REST Assured werden zunächst die Basisfunktionalitäten der REST-Schnittstelle mit POST, GET, PUT und DELETE überprüft.

Anschließend werden verschiedene HTTP-Statuscodes und die Fehlermeldungen, die von den ExceptionHandlern von Spring MVC erstellt wurden, überprüft. Dazu werden gezielt Fehler durch falsche Parameter beim PUT, GET und DELETE erzeugt.

Abschließend wird der Inhalt einer Liste mit Objekten vom Typ Customer überprüft. Dazu wird in einem Testfall ein eigener Hamcrest-Matcher und in dem anderen Testfall ein JSONPath verwendet.

Test eines POST-Requests

In diesem Test wird überprüft, ob der Return-Code 201 (Created) zurück geliefert wird und ob die location im Header gesetzt wurde.

Die Zeile .log().all() sorgt für eine Debug-Ausgabe auf der Konsole und dient hier nur als Hinweis, dass die Möglichkeit besteht, Logausgaben zu erzeugen.

@Test
public void createACustomerShouldReturnHTTP201() throws Exception {

	given()
		.spec(requestSpecBuilder.build())
		.log().all()
	.when()
		.post("/")
	.then()
		.statusCode(201)
		.headers("location", containsString("/customers/"));
}

Test eines PUT-Requests

Bei diesem Test, wird ein Customer angelegt und das Attribute Name geändert. Anschließend wird überprüft, ob der Statuscode 204 (No Content) zurückgeliefert wurde.

@Test
public void updateACustomerShouldReturnHTTP204() throws Exception {
	String customerId = createCustomer();

	ResponseSpecBuilder noContentInResponse = new ResponseSpecBuilder();
	noContentInResponse.expectBody(is("")).expectContentType("");

	given()
		.spec(requestSpecBuilder.build())
		.body("{\"Name\":\""+ANY_NAME+"\"}")
		.pathParam("id", customerId)
	.when()
		.put("/{id}")
	.then()
		.statusCode(204)
		.spec(noContentInResponse.build());
}

Die Methode createCustomer() wird in den folgenden Tests verwendet, um einen Customer anzulegen. Prinzipiell wird hier ein POST an die REST-Schnittstelle geschickt und die location im Header ausgewertet, um die customerId zu bestimmen.

private String createCustomer() {
	Response response  = given().spec(requestSpecBuilder.build()).body("").post("/");

	String customerLocation = response.header("location");

	return customerLocation.substring(customerLocation.lastIndexOf("/")+1, customerLocation.length());
}

Test eines GET-Requests

Bei diesem Test wird mit einem HTTP-GET überprüft, ob ein initial angelegt Customer eine gültige id hat und ob die anderen Werte auf null gesetzt sind.

@Test
public void getACustomer() throws Exception {
	String customerId = createCustomer();

	given()
		.pathParam("id", customerId)
	.when()
		.get("/{id}")
	.then()
		.statusCode(200)
		.contentType(ContentType.JSON)
		.body("id", is(customerId))
		.body("name", is(nullValue()))
		.body("created", is(nullValue()));
}

Test eines DELETE-Request

Der Test überprüft, ob sich ein Customer mit einem HTTP-DELETE wieder löschen läßt.

@Test
public void deleteACustomer() throws Exception {
	String customerId = createCustomer();

	given()
		.spec(requestSpecBuilder.build())
		.pathParam("id", customerId)
		.log().headers()
	.when()
		.delete("/{id}")
	.then()
		.statusCode(200);
}

Test von HTTP-Codes eines PUT

Die folgenden beiden Testfälle überprüfen, ob bei invaliden Parametern der richtige HTTP-Statuscode und die erwartete Fehlermeldung zurückgeliefert wird.

@Test
public void updateANotExistingCustomerShouldReturnHTTP404() throws Exception {

	given()
		.spec(requestSpecBuilder.build())
		.body("{\"Name\":\""+ANY_NAME+"\"}")
		.pathParam("id", CUSTOMER_ID_DOES_NOT_EXIST)
	.when()
		.put("/{id}")
	.then()
		.statusCode(404)
		.body(is("{\"reason\":\"Customer with id '"+CUSTOMER_ID_DOES_NOT_EXIST+"' not found.\"}"));
}

@Test
public void updateACustomerWithInvalidKeyShouldReturnHTTP400() throws Exception {
	String customerId = createCustomer();

	given()
		.spec(requestSpecBuilder.build())
		.body("{\""+INVALID_ARGUMENT+"\":\""+ANY_NAME+"\"}")
		.pathParam("id", customerId)
	.when()
		.put("/{id}")
	.then()
		.statusCode(400)
		.body(is("{\"reason\":\"The mandatory argument 'Name' is missing in the request.\"}"));
}

Test von HTTP-Codes eines GET

Dieser Testfall überprüft, ob die Suche nach einem Customer, der nicht existiert, die richtige Fehlermeldung und den Statuscode 404 zurückliefert.


@Test
public void getANotExistingCustomerShouldReturnHTTP404() throws Exception {
	given()
		.spec(requestSpecBuilder.build())
		.pathParam("id", CUSTOMER_ID_DOES_NOT_EXIST)
	.when()
		.get("/{id}")
	.then()
		.statusCode(404)
		.body(is("{\"reason\":\"Customer with id '"+CUSTOMER_ID_DOES_NOT_EXIST+"' not found.\"}"));
}

Test von HTTP-Codes eines DELETE

Dieser Test überprüft, ob die Fehlermeldung, die beim Löschen eines Customer, der nicht existiert, richtig ist.

@Test
public void deleteANotExistingCustomerShouldReturnHTTP404() throws Exception {
	given()
		.spec(requestSpecBuilder.build())
		.pathParam("id", CUSTOMER_ID_DOES_NOT_EXIST)
	.when()
		.delete("/{id}")
	.then()
		.statusCode(404)
		.body(is("{\"reason\":\"Customer with id '"+CUSTOMER_ID_DOES_NOT_EXIST+"' not found.\"}"));
}

Test des JSON-Contents mit JSONPath

Mit einem JSONPath läßt sich die JSON-Response relativ einfach parsen. D.h. es ist mit wenigen Kommandos möglich den Inhalt nach bestimmten Werten zu durchsuchen. In dem Test wird einfach nur überprüft, ob ein Customer mit einer bestimmten customerId in der Rückgabeliste enthalten ist. Mehr zu JSONPath findet man in diesem Blogpost: Parsing JSON documents with REST Assured JsonPath und die der Dokumentation von REST Assured JSON (using JsonPath).

@Test
public void getAllCustomersWithJSONPath() throws Exception {
	String customerId = createCustomer();
	Response response =
	given()
		.log().all()
	.when()
		.get("/")
	.then()
		.statusCode(200)
	.extract()
		.response();

	// Test with JsonPath
	List<String> idList = from(response.body().asString()).getList("id", String.class);
	assertThat(customerId, isIn(idList));
}

Eine Liste mit weiteren Funktionalitäten der API und Beispiele findet man in der JavaDoc der Klasse JsonPath.

Test des JSON-Contents mit einem Hamcrest-Matcher

In diesem Test wird ein eigener Hamcrest-Matcher erstellt, um zu überprüfen, ob die Rückgabeliste die customerId enthält.

Um einen Hamcrest-Matcher zu erstellen, müssen die beiden Methoden des TypeSafeMatcher implementiert werden. In dem Beispiel wird der String mit der JSON-Response einfach mit einem indexOf untersucht.

@Test
public void getAllCustomersWithMatcher() throws Exception {
	String customerId = createCustomer();

	given()
		.log().all()
	.when()
		.get("/")
	.then()
		.statusCode(200)
		.body(containsCustomerId(customerId));
}


private Matcher<String> containsCustomerId(String customerId) {
	final String id = customerId;

	return new TypeSafeMatcher<String>() {

		@Override
		public void describeTo(Description description) {
			description.appendText("customerId="+id+" is JSON response. ({\"id\":\""+id+"\",\"name\":null,\"created\":null})");
		}

		@Override
		protected boolean matchesSafely(String jsonResponse) {
			if(jsonResponse.indexOf("\"id\":\""+id+"\"")>=0) {
				return true;
			}
			return false;
		}
	};

}

Beispielcode

Der komplette Quellcode kann bei GitHub unter URL https://github.com/hameister/CustomerService heruntergeladen werden.

Dieser Stand kann mit git checkout cdb7dae6ee183223089a6ff3d105467cb886faf9 ausgecheckt werden.

Anmerkungen

Damit die Tests ausführbar sind, muss der Tomcat-Server laufen und die Applikation mit dem zu testenden REST-Service muss deployed sein. Für automatisierte Tests kann das ein Nachteil sein. Deshalb bietet beispielsweise Arquillian eine Möglichkeit diesen Schwachpunkt zu beseitigen.