We have now seen a couple of examples of JUnit unit tests. If we wanted to, we could go ahead and write unit tests to test every single method across our entire project.
Rather than take such a fine-grained approach to testing, we are going to pursue an alternative strategy known commonly as "end-to-end testing". In this type of testing we test the functionality of our entire system as a whole. This means sending requests to our controllers, letting the controllers talk to real services instead of mock services, and letting the services interact with the database.
To do this type of testing we are going to be writing a bunch of tests that test individual controller methods. In the course of the examples below I am going to end up testing just about every controller method in the auction system.
To do end-to-end testing I am going to be using a library named "REST Assured". This library was designed from the ground up to do end-to-end testing with REST server applications.
To use this testing library we start by adding a dependency to our project:
<dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <version>6.0.0</version> <scope>test</scope> </dependency>
When we do end-to-end testing we will be testing the entire application, including the parts of the application that interact with the database. Since testing is going to dump a bunch of test data into our database, a best practice is to use a separate database for testing. Fortunately, Spring Boot makes it really easy to do that. All of the test code I am going to show today is designed to work with the 'auctiontest' database. That way our tests will be free to dump a bunch of test data into the database without messing up the main auction database.
To tell Spring Boot to use a different database for tests, we create a new properties file named 'application-test.properties' and put this code in it:
spring.application.name=jpaauction server.port=8085 spring.datasource.url=jdbc:mysql://localhost:3306/auctiontest spring.datasource.username=student spring.datasource.password=Cmsc250! spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto = create
Note that the settings here point the application at the auctiontest database and not the usual auction database. We tell Spring Boot to switch to these alternative properties by switching to a different profile. We do this by attaching the annotation
@ActiveProfiles("test")
to our test classes.
Since I am going to be testing a lot of controller methods, I have broken my end-to-end test code down into three distinct classes.
The first class uses REST Assured to do some basic setup for our later tests. I am going to call the controller methods needed to create a few users in my system.
@SpringBootTest(classes=JpaauctionApplication.class,webEnvironment = WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
public class APISetupTests {
private static UserDTO testSeller;
private static UserDTO testBuyerOne;
private static UserDTO testBuyerTwo;
@BeforeAll
public static void setup() {
RestAssured.port = 8085;
RestAssured.baseURI = "http://localhost";
testSeller = new UserDTO();
testSeller.setName("TestSeller");
testSeller.setPassword("hello");
testBuyerOne = new UserDTO();
testBuyerOne.setName("BuyerOne");
testBuyerOne.setPassword("hello");
testBuyerTwo = new UserDTO();
testBuyerTwo.setName("BuyerTwo");
testBuyerTwo.setPassword("hello");
}
@Test
public void postSeller() {
given()
.contentType("application/json")
.body(testSeller)
.when().post("/users")
.then()
.statusCode(anyOf(is(201),is(409)));
}
@Test
public void postBuyers() {
given()
.contentType("application/json")
.body(testBuyerOne)
.when().post("/users")
.then()
.statusCode(anyOf(is(201),is(409)));
given()
.contentType("application/json")
.body(testBuyerTwo)
.when().post("/users")
.then()
.statusCode(anyOf(is(201),is(409)));
}
}
This first set of tests follows the same basic pattern we have seen already: we do setup in a @BeforeAll method, and then run some tests. The tests are designed to insert three users into our system: a seller and two buyers.
REST Assured tests all take the following form:
given() // Set up the request .when() // Run the request .then() // Check the response
The specifics of what happens in each of the three sub-parts here will differ from example to example. Over the course of all of the examples I will show in these notes you will be getting a pretty good sense for what the library can do.
In all three of the examples above we are doing post requests. As part of the setup we will specify the body of the request along with specifying the content type for the request. The main check we will want to perform after the response comes back is to check the response code. There are two possibilities here: if we are posting a particular user for the first time we will expect to get back a status code of 201, CREATED. Because we may very well run this test multiple times, there is also the possibility that we will be making duplicate posts. For those the controller should respond with the status code of 409, DUPLICATE.
To assert that the status code that comes back is either 201 or 409 we are going to be using Hamcrest matchers. These are special matching functions offered by the Hamcrest library (REST Assured includes that library automatically). These matching functions allow us to assert that a particular value (the status code in this case) meet a particular set of conditions.
More detailed information about Hamcrest matchers is available at hamcrest.org.
Now that we have tested whether or not we can post a set of users to the application, it's time for some more extensive tests. The next test class I created will test basic actions such as
Here is the code for our next test class:
@SpringBootTest(classes=JpaauctionApplication.class,webEnvironment = WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class APIBasicTests {
private static UserDTO testSeller;
private static UserDTO testBuyerOne;
private static UserDTO testBuyerTwo;
private static String sellerToken;
private static String buyerOneToken;
private static String buyerTwoToken;
private static String auctionID;
@BeforeAll
public static void setup() {
RestAssured.port = 8085;
RestAssured.baseURI = "http://localhost";
testSeller = new UserDTO();
testSeller.setName("TestSeller");
testSeller.setPassword("hello");
testBuyerOne = new UserDTO();
testBuyerOne.setName("BuyerOne");
testBuyerOne.setPassword("hello");
testBuyerTwo = new UserDTO();
testBuyerTwo.setName("BuyerTwo");
testBuyerTwo.setPassword("hello");
}
@Test
@Order(1)
public void testLogin() {
sellerToken = given()
.contentType("application/json")
.body(testSeller)
.when().post("/users/login")
.then()
.statusCode(200)
.extract().asString();
buyerOneToken = given()
.contentType("application/json")
.body(testBuyerOne)
.when().post("/users/login")
.then()
.statusCode(200)
.extract().asString();
buyerTwoToken = given()
.contentType("application/json")
.body(testBuyerTwo)
.when().post("/users/login")
.then()
.statusCode(200)
.extract().asString();
}
@Test
@Order(2)
public void testPostProfile() {
ShippingDTO testSellerShipping = new ShippingDTO();
testSellerShipping.setDisplayname("Test Seller");
testSellerShipping.setAddressone("711 E Boldt Way");
testSellerShipping.setCity("Appleton");
testSellerShipping.setState("WI");
testSellerShipping.setZip("54911");
ProfileDTO testSellerProfile = new ProfileDTO();
testSellerProfile.setFullname("Test Seller");
testSellerProfile.setBio("I am a test seller");
testSellerProfile.setEmail("seller@sales.com");
testSellerProfile.setPhone("9205551212");
testSellerProfile.setShipping(testSellerShipping);
given()
.header("Authorization","Bearer "+sellerToken)
.contentType("application/json")
.body(testSellerProfile)
.when().post("/users/profile")
.then()
.statusCode(anyOf(is(201),is(409)));
}
@Test
@Order(3)
public void testPostAuction() {
AuctionDTO auction = new AuctionDTO();
auction.setItem("Laptop");
auction.setDescription("Slightly used laptop");
auction.setReserve(2000);
auction.setOpens(LocalDate.now().toString());
auction.setCloses(LocalDate.now().plusDays(4).toString());
given()
.header("Authorization","Bearer "+sellerToken)
.contentType("application/json")
.body(auction)
.when().post("/auctions")
.then()
.statusCode(anyOf(is(201),is(409)));
}
@Test
@Order(4)
public void testGetAuctions() {
auctionID =
when()
.get("/auctions")
.then()
.statusCode(200)
.extract()
.path("[0].auctionid");
System.out.println(auctionID);
}
@Test
@Order(5)
public void testPlaceBids() {
BidDTO bid = new BidDTO();
bid.setBid(2500);
given()
.header("Authorization","Bearer "+buyerOneToken)
.contentType("application/json")
.body(bid)
.when()
.post("/auctions/"+auctionID+"/bids")
.then()
.statusCode(201);
bid.setBid(3000);
given()
.header("Authorization","Bearer "+buyerTwoToken)
.contentType("application/json")
.body(bid)
.when()
.post("/auctions/"+auctionID+"/bids")
.then()
.statusCode(201);
}
}
An important JUnit option that I had to exercise here involves ordering tests. Normally JUnit gives us no guarantees that the tests in a test class will run in any particular order. For what we are doing here we need certain things to happen in a carefully ordered sequence. For example, you can't post bids to an auction until you have first posted the auction. To control the order of our tests I have attached an @Order annotation to each test that tells JUnit the precise order to run the tests in. For the test ordering to work correctly we also need to attach the annotation
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
to the class as a whole.
The tests above include some special operations I had to perform in REST Assured. One special operation is obtaining the body of a response as a String. You will see an example of that in the testLogin() test. To obtain the body of a response we use the extract() method in the then() section of the test. If we follow up the call to extract() with a call to asString() we will get the entire body of the response as a string.
Another example of extracting data from the body of the response appears in the testGetAuctions() method. The test in this case fetches a list of all auctions currently available. We don't need to make use of the full list that comes back in the response; instead, we only want to grab the id of one of the auctions in the body. To pick out just one piece of data from the body we first use the extract() method to get the body, but then we call the path() method to extract one particular piece of data from the body. The path() method makes use of a special JsonPath syntax to pick out one item in the body. As you can see in the example, we use the path specifier "[0].auctionid" to specify that we want to pick out just the auctionid property of the first auction in the returned list of auctions. REST Assured uses an implementation of the JsonPath specification called GPath. More details about the full syntax of GPath along with examples is available here.
Another important example here is inserting an authorization header in a request. As you can see in several examples above we can do this by using the header() method in the given() section of the test.
The most complex part of the auction server is the logic to handle all of the steps involved in a purchase. To test this more complex sequence of steps I wrote a second test class:
@SpringBootTest(classes=JpaauctionApplication.class,webEnvironment = WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class APIPurchaseTests {
private static UserDTO testSeller;
private static UserDTO testBuyer;
private static String sellerToken;
private static String buyerToken;
private static String purchaseID;
@BeforeAll
public static void setup() {
RestAssured.port = 8085;
RestAssured.baseURI = "http://localhost";
testSeller = new UserDTO();
testSeller.setName("TestSeller");
testSeller.setPassword("hello");
testBuyer = new UserDTO();
testBuyer.setName("BuyerTwo");
testBuyer.setPassword("hello");
}
@Test
@Order(1)
public void prerequisites() {
sellerToken = given()
.contentType("application/json")
.body(testSeller)
.when().post("/users/login")
.then()
.statusCode(200)
.extract().asString();
buyerToken = given()
.contentType("application/json")
.body(testBuyer)
.when().post("/users/login")
.then()
.statusCode(200)
.extract().asString();
given()
.header("Authorization","Bearer "+sellerToken)
.when().get("/purchases/concludeauctions")
.then().statusCode(200);
}
@Test
@Order(2)
public void processOffer() {
// Get the buyer's offers and grab the purchase id of the first one
purchaseID = given()
.header("Authorization","Bearer "+ buyerToken)
.when().get("/users/offers")
.then()
.statusCode(200)
.extract()
.path("[0].purchaseid");
// Post a shipping address for the buyer
ShippingDTO shipping = new ShippingDTO();
shipping.setDisplayname("Eager Buyer");
shipping.setAddressone("711 E Boldt Way");
shipping.setCity("Appleton");
shipping.setState("WI");
shipping.setZip("54911");
given()
.header("Authorization","Bearer "+ buyerToken)
.contentType("application/json")
.body(shipping)
.when().post("/users/shipping")
.then()
.statusCode(201);
// Get the buyer's shipping addresses and grab the id of the first one
int shippingid = given()
.header("Authorization","Bearer "+ buyerToken)
.when().get("/users/shipping")
.then()
.statusCode(200)
.extract()
.path("[0].shippingid");
// Post an offer response accepting the offer
OfferResponse or = new OfferResponse();
or.setPurchaseid(purchaseID);
or.setAccept(true);
or.setShippingid(shippingid);
given()
.header("Authorization","Bearer "+ buyerToken)
.contentType("application/json")
.body(or)
.when().post("/purchases/offerresponse")
.then()
.statusCode(202);
}
@Test
@Order(3)
public void chargeShipping() {
// Get the seller's sales and grab the purchase id of the first one
purchaseID = given()
.header("Authorization","Bearer "+ sellerToken)
.when().get("/users/accepted")
.then()
.statusCode(200)
.extract()
.path("[0].purchaseid");
// Post a shipping charge
ShippingCharge sc = new ShippingCharge();
sc.setPurchaseid(purchaseID);
sc.setCharge(599);
given()
.header("Authorization","Bearer "+ sellerToken)
.contentType("application/json")
.body(sc)
.when().post("/purchases/billshipping")
.then()
.statusCode(202);
}
@Test
@Order(4)
public void processBill() {
// Get the buyer's bills and grab the purchase id of the first one
purchaseID = given()
.header("Authorization","Bearer "+ buyerToken)
.when().get("/users/billed")
.then()
.statusCode(200)
.extract()
.path("[0].purchaseid");
// Post an bill response accepting the bill
BillResponse br = new BillResponse();
br.setPurchaseid(purchaseID);
br.setAccepted(true);
given()
.header("Authorization","Bearer "+ buyerToken)
.contentType("application/json")
.body(br)
.when().post("/purchases/billresponse")
.then()
.statusCode(202);
}
@Test
@Order(5)
public void ship() {
// Get the seller's bills and grab the purchase id of the first one
purchaseID = given()
.header("Authorization","Bearer "+ sellerToken)
.when().get("/users/sold")
.then()
.statusCode(200)
.extract()
.path("[0].purchaseid");
// Post a shipping confirmation
ShippingConfirmation sc = new ShippingConfirmation();
sc.setPurchaseid(purchaseID);
sc.setTracking("ZZZ-ZZ-ZZZ");
given()
.header("Authorization","Bearer "+ sellerToken)
.contentType("application/json")
.body(sc)
.when().post("/purchases/confirmshipping")
.then()
.statusCode(202);
// Confirm that there is a shipping entry
given()
.header("Authorization","Bearer "+ buyerToken)
.when().get("/users/shipped")
.then()
.statusCode(200)
.body("$.size()", greaterThan(0));
}
}
The REST Assured code here uses the same operations I used in the first set of tests. One new thing here appears in the very last test. That test makes use of a JsonPath assertion function to check that the body of the response is a non-empty array. To test this assertion we use the body() method. This method takes two parameters, a JsonPath expression that indicates what value from the body we want to look at, and a matcher expression that specifies what we want to assert about that value.