Advanced Behavior-Driven Development (BDD) using Rest-Assured

Behaviour-Driven Development (BDD) is an improvement over Test-Driven Development, but in more of a social manner rather than in strictly technical terms. Specifically, it enriches the description of how a system should behave under testing by using language that is accessible to all stakeholders. If you want learn the reasoning that led to BDD Dan North, the developer, offers what led to his discovery.

The catalyst for my use of BDD was a requirement to test a REST api that was being used by a number of clients including an Angular web app, an IOS app and an Android app.

The REST api itself was implemented in Drupal (PHP) but being REST (via HTTP) it was agnostic as to the programming language (as I am) used for testing. Initially I attempted to use a combination of JUnit along with the Apache Commons HttpClient library but this proved cumbersome; hiding what was being tested behind the supporting code. I then explored several BDD tools before settling on Rest-assured. I like it because in is mature, has an active code-base and a nice DSL (quite a feat since it is written in Java!).

The usage guide will get you up and running so I will go straight into code since I assume that is why you are here. This particular example solution deals with the common case of needing a user to be logged in to perform certain tests, logout for instance.  In Rest-assured Filters can be used to realize this need.

package com.raymondmauge.go.api.tests.filters;

import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Logger;

import com.jayway.restassured.filter.Filter;
import com.jayway.restassured.filter.FilterContext;
import com.jayway.restassured.path.json.JsonPath;
import com.jayway.restassured.response.Cookie;
import com.jayway.restassured.response.Cookies;
import com.jayway.restassured.response.Header;
import com.jayway.restassured.response.Headers;
import com.jayway.restassured.response.Response;
import com.jayway.restassured.specification.FilterableRequestSpecification;
import com.jayway.restassured.specification.FilterableResponseSpecification;

/**
 * Examines a response for user authentication identifiers and if found adds these
 * to subsequent requests using this filter.
 * Adds header, X-CSRF-Token: {token}
 * Set Cookie, Set-Cookie: {sessionName}={sessionId}
 * @author rmauge
 *
 */
public class AuthFilter implements Filter {
	
	private static Logger log = Logger.getLogger(AuthFilter.class);
	
	private String headerNameKey;
	private String sessionNameKey;
	private String sessionIdKey;
	private String tokenNameKey;
	
	private String sessionName;
	private String sessionId;
	private String token;
	
	public AuthFilter(String headerName,
					  String sesNameKey,
					  String sesIdKey,
					  String tokenKey) {
		headerNameKey = headerName;
		sessionNameKey = sesNameKey;
		sessionIdKey = sesIdKey;
		tokenNameKey = tokenKey;
	}

	public Response filter(FilterableRequestSpecification requestSpec,
			FilterableResponseSpecification responseSpec, FilterContext ctx) {
		
		if (StringUtils.isNotBlank(sessionName) &&
				StringUtils.isNotBlank(sessionId) &&
				StringUtils.isNotBlank(token)) {

			Headers headers = requestSpec.getHeaders();
			if (!headers.hasHeaderWithName(headerNameKey) ) {

				requestSpec.header(new Header(headerNameKey, token));
			}

			Cookies cookies = requestSpec.getCookies();
			if (!cookies.hasCookieWithName(sessionName)) {
				requestSpec.cookie(new Cookie.Builder(sessionName, sessionId).build());
			}
			return ctx.next(requestSpec, responseSpec);
		} else {
			final Response response = ctx.next(requestSpec, responseSpec);
			String json = response.asString();
			JsonPath jsonPath = new JsonPath(json);
		
			sessionName = jsonPath.getString(sessionNameKey);
			sessionId = jsonPath.getString(sessionIdKey);
			token = jsonPath.getString(tokenNameKey);
		
			// Don't log sensitive credentials
			log.debug(String.format("Got sessionName: %s, sessionId: %s, token: %s",
								StringUtils.abbreviate(sessionName, 10),
								StringUtils.abbreviate(sessionId, 10),
								StringUtils.abbreviate(token, 10)
								));
			return response;
		}
	}
}

For this api when a user is successfully logged in then a json response is returned that contains the authentication information:
sessionName, sessionId, and token.

If these are found (only during login) they are stored in member variables of the filter. Otherwise they are retrieved and used for subsequent requests.

The filter is setup for use by JUnit/Rest-assured in a base class but it can of course be the same class:


package com.raymondmauge.go.api.tests;

import org.apache.log4j.Logger;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import com.raymondmauge.go.api.tests.exceptions.AuthException;
import com.raymondmauge.go.api.tests.filters.AuthFilter;
import com.raymondmauge.go.api.tests.util.Config;
import com.raymondmauge.go.api.tests.util.TestFixtures;

import com.jayway.restassured.RestAssured;
import com.jayway.restassured.builder.RequestSpecBuilder;
import com.jayway.restassured.specification.RequestSpecification;

/**
 * Initializes variables that are useful for many tests.
 * System properties expected:
 * "settings_file": YAML file containing settings. Default,  "settings.yml"
 * @author rmauge
 *
 */
public abstract class BaseTest {
	
	protected static Config config = null;
	protected static RequestSpecification requestSpec = null;
	protected static AuthFilter authFilter = null;
	private static Logger log = Logger.getLogger(BaseTest.class);
	
	@BeforeClass
	public static void baseSetUp() throws AuthException {
		log.debug("BaseTest setup");
		config = new Config(System.getProperty("settings_file", Config.DEFAULT_SETTINGS_FILENAME));
		authFilter = TestFixtures.getAuthFilter(config);
		RequestSpecBuilder builder = TestFixtures.getDefaultBuilder(config);
		requestSpec = builder.build();
	}

	@AfterClass
	public static void baseTearDown() {
		log.debug("BaseTest teardown");
		RestAssured.reset();
		config = null;
		requestSpec = null;
		authFilter = null;
	}
}

A helper class TestFixtures is used to intialize a new filter instance by reading in user login username and password etc from a config file (yml). This can be used directly in code but this makes changes easier.

/**
	 * 
	 * This is an expensive operation but the result can be re-used. 
	 * It expects that the following yml properties are set:
	 * client_csrf_header_name, user_session_name_key, user_session_id_key, user_token_key
	 * @param config yml file with test properties
	 * @return A filter that has the proper credentials to send a request on 
	 * behalf of a logged in user.
	 * @throws Exception 
	 */
	@SuppressWarnings("unchecked")
	public static AuthFilter getAuthFilter(Config config) throws AuthException {
		AuthFilter authFilter = new AuthFilter(
				config.get("client_csrf_header_name"),
				config.get("user_session_name_key"),
				config.get("user_session_id_key"),
				config.get("user_token_key")
				);
		
		JSONObject requestBody = new JSONObject();
		requestBody.put("email", config.get("user_email_valid"));
		requestBody.put("password", config.get("user_password_valid"));
		Response response =
		given().
			filter(authFilter).
			spec(getDefaultBuilder(config).build()).
			body(requestBody.toJSONString()).
		when().
			post("/user/login").
		then().
			extract().
			response();
		
		if (response.statusCode() != 200) {
			throw new AuthException(
					String.format("Authentication failed: HTTP %d", response.statusCode()));
		}
		
		return authFilter;
	}

If the above code is successful then the filter is ready for use. I am using rest-assured outside of a test here because it is so awesome. But now I have to manually check for an invalid HTTP status code :(.

Now for the actual usage of the filter in a test:

package com.raymondmauge.go.api.tests;

import static com.jayway.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;

import org.json.simple.JSONObject;
import org.junit.Test;
import com.raymondmauge.go.api.tests.exceptions.AuthException;
import com.raymondmauge.go.api.tests.filters.AuthFilter;
import com.raymondmauge.go.api.tests.util.TestFixtures;

/**
 * Tests User API
 * @author rmauge
 *
 */

public class UsersTest extends BaseTest {
	
	@Test
	public void getUser() {
		given().
			filter(authFilter).
			spec(requestSpec).
		when().
			get("/user/").
		then().
			statusCode(200).
			body("user.email", equalTo(config.get("user_email_valid")));
	}
}

The filter that is populated in the Base class is now used for any tests that require a logged in user!

I hope that this has been helpful in exposing you to BDD.

P.S
Here is the code that implements reading from the yaml config file. It uses the Snake YAML library

package com.raymondmauge.go.api.tests.util;

import java.io.InputStream;
import java.util.Map;

import org.yaml.snakeyaml.Yaml;

/**
 * Helper class used to get properties from a yml file
 * @author rmauge
 *
 */
public class Config {
	private Map settings;

	@SuppressWarnings("unchecked")
	public Config(String configFile) {
		if (settings == null) {
			InputStream in = Thread.currentThread().
							 getContextClassLoader().
							 getResourceAsStream(configFile);
		
			settings = (Map) new Yaml().loadAs(in, Map.class);
		}
	}
	
	public String get(String key) {
		return settings.get(key);
	}
	
	public String get(String key, String def) {
		String val = get(key);
		return (val == null ? def: val);
	}
}

Advertisements
This entry was posted in Uncategorized and tagged , , , , . Bookmark the permalink.

2 Responses to Advanced Behavior-Driven Development (BDD) using Rest-Assured

  1. Entaro Adun says:

    Hi,
    great post. Is the source code available somewhere in a public repository ?

    Best regards,
    entaro

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s