Question
Using Cucumber and Java17, I refactored functional tests for a basic CRUD app as I found the previous tests using special variables for different test scenarios and I felt they were brittle and difficult to work with. So I came up with the following.
I'm being told that the solution, specifically the StepLabel stuff, is complicated and confusing. I agree, but I'm not sure how to simplify this. I'm also being told the java docs are noisy and unnecessary, and that the separating blocks in the Steps (used to organise the steps) are also restrictive. One of the requirements is to keep the code maintainable by a QA engineer.
What is your feedback on the architectural approach I've taken? And how can I simplify the StepLabel component? And finally, do you agree with the comments I've quoted above? Thanks!
Architecture description
The CRUD app records have a customisable record id, some string attributes and an optional list. Here's the request and response objects in the test:
@Data
public class ContactRequest {
private String id;
private String description;
private String contactInfo;
private String someField;
private List<KnownAssociate> knownAssociates = new ArrayList<>();
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@SuperBuilder(toBuilder = true)
public class ContactResponse extends BaseResponse {
private String id;
private String description;
private String contactInfo;
private String someField;
private List<KnownAssociate> knownAssociates;
private Instant createdDateTime;
private Instant updatedDateTime;
}
We have a few scenarios to test: basic CRUD (which includes an additional 'isExists' endpoint), duplicate id creation, and testing of an additional 'listAll' endpoint.
@contact_app_service_api
Feature: To allow services to perform CRUD operations on the Contact table in the database
@end_to_end_contact @cleanup_delete_contact
Scenario Outline: <KnownAssociate> Contact CRUD lifecycle
When the user creates a contact with known associate <KnownAssociate>
Then the contact should be created
When the user retrieves the contact
Then the contact should be retrieved
When the user updates the contact
Then the contact should be updated
When the user checks if a contact exists
Then the contact should be shown to exist
When the user deletes the contact
Then the contact should be deleted
When the user checks if a contact exists
Then the contact should be shown to not exist
# Delete the contact using after hook tag @cleanup_delete_contact
Examples:
| KnownAssociate |
| None |
| TypeOne |
| TypeTwo |
| TypeThree |
@create_invalid_contact @cleanup_delete_contact
Scenario: Create contact with duplicate contact id fails
Given a contact exists
When the user creates a second contact with the same contact id
Then an error message is returned: "Contact id: <contactId> already exists" and HTTP status 400 after "creation" attempt
# Delete the contact using after hook tag @cleanup_delete_contact
@get_all_contact_details
Scenario: Create, get and delete multiple contacts
When the user creates 2 contacts
And the user retrieves all existing contacts
Then the created 2 contacts exist in the list
And the 2 contacts should be deleted
My intention is that the Step definition class should not hold state. An Actions class will hold state and deal with the API calls and assertion evaluations.
public class ContactAppSteps {
private final ContactAppActions contactAppActions;
public ContactAppSteps(ContactAppActions contactAppActions) {
this.contactAppActions = contactAppActions;
}
/*
* NORMAL CRUD OPERATIONS
*/
@When("the user creates a contact with delivery channel {}")
public void contactCreation(String knownAssociates) {
ContactRequest contactRequest;
if(knownAssociates == null || "none".equalsIgnoreCase(knownAssociates)) {
contactRequest = contactAppActions.getDefaultContactRequest();
} else {
contactRequest = contactAppActions.getDefaultContactRequest(KnownAssociate.valueOf(knownAssociates));
}
contactAppActions.createContact(contactRequest);
}
@Given("a contact exists")
public void contactCreation() {
contactAppActions.createContact();
}
@When("the user retrieves the contact")
public void contactRetrieval() {
contactAppActions.retrieveContact();
}
@When("the user checks if a contact exists")
public void contactExists() {
contactAppActions.checkContactExists();
}
@When("the user updates the contact")
public void contactUpdate() {
contactAppActions.updateContact();
}
@When("the user deletes the contact")
public void contactDeletion() {
contactAppActions.deleteContact();
}
@When("the user retrieves all existing contacts")
public void allContactRetrieval() {
contactAppActions.retrieveAllContacts();
}
/*
* SPECIAL OPS
*/
@When("the user creates a second contact with the same contact id")
public void createDuplicateContact() {
String existingContactId = contactAppActions.retrieveContact().getId();
ContactRequest contactRequest = contactAppActions.getDefaultContactRequest(existingContactId);
contactAppActions.createContact(contactRequest);
}
@When("the user creates {int} contacts")
public void createMultipleContacts(int contactCount) {
for(int i = 0; i < contactCount; i++) {
contactAppActions.createContact(contactAppActions.getDefaultContactRequest());
}
}
@Then("the created {int} contacts exist in the list")
public void checkIfCreatedContactsExistInList(int contactCount) {
List<ContactResponse> overallContactResponseList = contactAppActions.fetchContactResponseListFromContext();
for(int i = 0; i < contactCount; i++) {
ContactResponse contactResponse = contactAppActions.fetchContactResponseFromContext(new StepLabel(CREATION_RESPONSE, i));
assertThat(overallContactResponseList).contains(contactResponse);
}
}
@Then("the {int} contacts should be deleted")
public void deleteContacts(int contactCount) {
for(int i = 0; i < contactCount; i++) {
ContactResponse contactResponse = contactAppActions.fetchContactResponseFromContext(new StepLabel(CREATION_RESPONSE, i));
contactAppActions.deleteContact(contactResponse.getId());
}
}
/*
* EVALUATIONS
*/
@Then("the contact should be created")
public void contactShouldBeCreated() {
contactAppActions.evaluateCreation();
}
@Then("the contact should be retrieved")
public void contactShouldBeRetrieved() {
contactAppActions.evaluateRetrieval();
}
@Then("the contact should be shown to exist")
public void contactShouldExist() {
contactAppActions.evaluateCheckExists(true);
}
@Then("the contact should be shown to not exist")
public void contactShouldNotExist() {
contactAppActions.evaluateCheckExists(false);
}
@Then("the contact should be updated")
public void contactShouldBeUpdated() {
contactAppActions.evaluateUpdate();
}
@Then("the contact should be deleted")
public void contactShouldBeDeleted() {
contactAppActions.evaluateDeletion();
}
@Then("all existing contacts should be retrieved")
public void allContactsShouldBeRetrieved() {
contactAppActions.evaluateRetrieval();
}
@Then("an error message is returned: {string} and HTTP status {int} after {string} attempt")
public void errorMessageReturned(String message, int status, String operation) {
if("creation".equalsIgnoreCase(operation)) {
StepLabel creationStepLabel = new StepLabel(CREATION_RESPONSE);
if(message.contains("<contactId>")) {
String contactId = contactAppActions.fetchContactResponseFromContext(creationStepLabel).getId();
message = message.replace("<contactId>", contactId);
}
contactAppActions.evaluateErrorResponse(creationStepLabel.inc(), status, message);
} else {
throw new UnsupportedOperationException();
}
}
/*
* CLEANUP
*/
@After("@cleanup_delete_contact")
public void afterDeleteContact() {
contactAppActions.deleteAllCreatedContacts();
}
}
public class ContactAppActions {
public static final String CREATION_RESPONSE = "CREATION_RESPONSE";
public static final String RETRIEVAL_RESPONSE = "RETRIEVAL_RESPONSE";
public static final String CHECK_EXISTS_RESPONSE = "CHECK_EXISTS_RESPONSE";
public static final String UPDATE_RESPONSE = "UPDATE_RESPONSE";
public static final String DELETION_RESPONSE = "DELETION_RESPONSE";
private final StepLabelCounter stepLabelCounter = new StepLabelCounter();
private final ContactAppServiceClient contactAppServiceClient;
private final ContactAppContext contactAppContext;
public ContactAppActions(ContactAppServiceClient contactAppServiceClient,
ContactAppContext contactAppContext) {
this.contactAppServiceClient = contactAppServiceClient;
this.contactAppContext = contactAppContext;
}
/**
* Creates a ContactRequest with randomly generated data and a generated contact id but with no delivery channel
*/
public ContactRequest getDefaultContactRequest() {
return ContactFixtures.generateDefaultContact();
}
/**
* Creates a ContactRequest with randomly generated data and a generated contact id
*/
public ContactRequest getDefaultContactRequest(KnownAssociate knownAssociate) {
return ContactFixtures.generateDefaultContact(knownAssociate);
}
/**
* Creates a ContactRequest with randomly generated data but with no delivery channel
*/
public ContactRequest getDefaultContactRequest(String contactId) {
return ContactFixtures.generateDefaultContact(contactId);
}
/**
* Creates a ContactRequest with randomly generated data
*/
public ContactRequest getDefaultContactRequest(String contactId, KnownAssociate knownAssociate) {
return ContactFixtures.generateDefaultContact(contactId, knownAssociate);
}
/**
* Creates a ContactRequest with the given params. The `knownAssociateEntrys` is optional and is a CSV string
*/
public ContactRequest getParameterisedContactRequest(Map<String, String> params) {
return ContactFixtures.generateContactWithParams(params);
}
/**
* Create a contact using randomly generated data
* @return the created contact
*/
public ContactResponse createContact() {
return createContact(getDefaultContactRequest());
}
/**
* Create a contact using the provided request
* @return the created contact
*/
public ContactResponse createContact(ContactRequest contactRequest) {
contactAppContext.setContactRequest(contactRequest);
Response response = contactAppServiceClient.createContact(contactRequest);
contactAppContext.getResponseMap().put(stepLabelCounter.getNext(CREATION_RESPONSE), response);
return contactAppServiceClient.extractContactResponse(response);
}
/**
* Retrieve the contact using the last creation response's contact id
* @return the retrieved contact
*/
public ContactResponse retrieveContact() {
return retrieveContact(extract(contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(CREATION_RESPONSE))).getId());
}
/**
* Retrieve the contact using the provided contact id
* @return the retrieved contact
*/
public ContactResponse retrieveContact(String contactId) {
Response response = contactAppServiceClient.getContact(contactId);
contactAppContext.getResponseMap().put(stepLabelCounter.getNext(RETRIEVAL_RESPONSE), response);
return contactAppServiceClient.extractContactResponse(response);
}
/**
* Check if the contact exists, using the last creation response's contact id
* @return empty response
*/
public Response checkContactExists() {
return checkContactExists(extract(contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(CREATION_RESPONSE))).getId());
}
/**
* Check if the contact exists, using the provided contact id
* @return empty response
*/
public Response checkContactExists(String contactId) {
Response response = contactAppServiceClient.existsContact(contactId);
contactAppContext.getResponseMap().put(stepLabelCounter.getNext(CHECK_EXISTS_RESPONSE), response);
return response;
}
/**
* Create an update contact request using the last creation response's contact id and randomly generated data
* @return the updated contact
*/
public ContactResponse updateContact() {
String contactId = extract(contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(CREATION_RESPONSE))).getId();
return updateContact(contactId);
}
/**
* Create an update contact request using the provided contact id and randomly generated data
* @return the updated contact
*/
public ContactResponse updateContact(String contactId) {
ContactRequest contactUpdateRequest = getDefaultContactRequest(contactId);
return updateContact(contactUpdateRequest);
}
/**
* Create an update contact request using the provided request
* @return the updated contact
*/
public ContactResponse updateContact(ContactRequest contactUpdateRequest) {
contactAppContext.setContactRequest(contactUpdateRequest);
Response response = contactAppServiceClient.updateContact(contactUpdateRequest.getId(), contactUpdateRequest);
contactAppContext.getResponseMap().put(stepLabelCounter.getNext(UPDATE_RESPONSE), response);
return contactAppServiceClient.extractContactResponse(response);
}
/**
* Delete the contact using the last creation response's contact id
* @return empty response
*/
public Response deleteContact() {
String contactId = extract(contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(CREATION_RESPONSE))).getId();
return deleteContact(contactId);
}
/**
* Delete the contact using the provided contact id
* @return empty response
*/
public Response deleteContact(String contactId) {
Response response = contactAppServiceClient.deleteContact(contactId);
contactAppContext.getResponseMap().put(stepLabelCounter.getNext(DELETION_RESPONSE), response);
return response;
}
/**
* Retrieves all contacts
* @return list of all existing contacts
*/
public List<ContactResponse> retrieveAllContacts() {
List<ContactResponse> contactResponseList = contactAppServiceClient.getAllContacts();
contactAppContext.setContactResponseList(contactResponseList);
return contactResponseList;
}
/**
* Evaluate creation response against creation request
*/
public void evaluateCreation() {
evaluateCreation(stepLabelCounter.getLatest(CREATION_RESPONSE));
}
/**
* Evaluate equality of fields between provided step label's response and creation request
*/
public void evaluateCreation(StepLabel stepLabelForActualResponse) {
Response response = contactAppContext.getResponseMap().get(stepLabelForActualResponse);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_CREATED);
evaluateEquality(response);
}
/**
* Evaluate equality of fields between last retrieval response and last creation response
*/
public void evaluateRetrieval() {
evaluateRetrieval(stepLabelCounter.getLatest(RETRIEVAL_RESPONSE), stepLabelCounter.getLatest(CREATION_RESPONSE));
}
/**
* Evaluate equality of fields between provided step label's response and another provided step label's response.
* This method is provided for flexibility for non-standard flows
*/
public void evaluateRetrieval(StepLabel stepLabelForActualResponse, StepLabel stepLabelForExpectedResponse) {
Response actualResponse = contactAppContext.getResponseMap().get(stepLabelForActualResponse);
Response expectedResponse = contactAppContext.getResponseMap().get(stepLabelForExpectedResponse);
evaluateRetrieval(actualResponse, expectedResponse);
}
/**
* Evaluate equality of fields between provided response and another provided response.
* This method is provided for flexibility for non-standard flows and where context is not used
*/
public void evaluateRetrieval(Response actualResponse, Response expectedResponse) {
assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
evaluateEquality(actualResponse, expectedResponse);
}
/**
* Evaluate status of last existence check response
* @param exists check if response status shows contact exists (HTTP 200) or if doesn't exist (HTTP 404)
*/
public void evaluateCheckExists(boolean exists) {
Response response = contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(CHECK_EXISTS_RESPONSE));
evaluateCheckExists(response, exists);
}
/**
* Evaluate status of provided existence check response
* @param exists check if response status shows contact exists (HTTP 200) or if doesn't exist (HTTP 404)
*/
public void evaluateCheckExists(Response response, boolean exists) {
if(exists) {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
} else {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
}
}
/**
* Evaluate relevant equality and difference of fields between update response and update request,
* and update response and creation response, respectively
*/
public void evaluateUpdate() {
evaluateUpdate(stepLabelCounter.getLatest(UPDATE_RESPONSE), stepLabelCounter.getLatest(CREATION_RESPONSE));
}
/**
* Evaluate relevant equality and difference of fields between provided step label's response and latest generated
* contact request (this would be the update request if updateContact(_) was called), and provided step label's
* response and another provided step label's response, respectively
*/
public void evaluateUpdate(StepLabel stepLabelForActualResponse, StepLabel stepLabelForExpectedResponse) {
Response actualResponse = contactAppContext.getResponseMap().get(stepLabelForActualResponse);
Response expectedResponse = contactAppContext.getResponseMap().get(stepLabelForExpectedResponse);
ContactRequest updateContactRequest = contactAppContext.getContactRequest();
evaluateUpdate(actualResponse, expectedResponse, updateContactRequest);
}
/**
* Evaluate relevant equality and difference of fields between provided response and update request,
* and provided response and another provided response, respectively
*/
public void evaluateUpdate(Response actualResponse, Response expectedResponse, ContactRequest updateContactRequest) {
assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
evaluateDifference(extract(actualResponse), extract(expectedResponse));
evaluateEquality(extract(actualResponse), updateContactRequest);
}
/**
* Evaluate success of deletion of last delete response
*/
public void evaluateDeletion() {
Response response = contactAppContext.getResponseMap().get(stepLabelCounter.getLatest(DELETION_RESPONSE));
evaluateDeletion(response);
}
/**
* Evaluate success of deletion of provided response
*/
public void evaluateDeletion(Response response) {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
}
/**
* Evaluate error fields of provided response
*/
public void evaluateErrorResponse(StepLabel stepLabelForResponse, int expectedHttpStatus, String expectedErrorMessage) {
evaluateErrorResponse(contactAppContext.getResponseMap().get(stepLabelForResponse), expectedHttpStatus, expectedErrorMessage);
}
/**
* Evaluate error fields of provided response
*/
public void evaluateErrorResponse(Response response, int expectedHttpStatus, String expectedErrorMessage) {
assertThat(response.getStatusCode()).isEqualTo(expectedHttpStatus);
assertThat(extractError(response).getError().getMessage()).isEqualTo(expectedErrorMessage);
}
/**
* Evaluate equality of fields between the provided step label's response and latest generated contact request
*/
public void evaluateEquality(StepLabel stepLabelForActualResponse) {
evaluateEquality(extract(contactAppContext.getResponseMap().get(stepLabelForActualResponse)), contactAppContext.getContactRequest());
}
/**
* Evaluate equality of fields between the provided response and latest generated contact request
*/
public void evaluateEquality(Response actualResponse) {
evaluateEquality(extract(actualResponse), contactAppContext.getContactRequest());
}
/**
* Evaluate equality of fields between the provided contact response and request
* This method is provided for flexibility for non-standard flows and where context is not used
*/
public void evaluateEquality(ContactResponse actualContactResponse, ContactRequest expectedContactRequest) {
evaluateContactFieldsEquality(actualContactResponse, expectedContactRequest.getId(), expectedContactRequest.getDescription(), expectedContactRequest.getContactInfo(), expectedContactRequest.getSomeField(), expectedContactRequest.getKnownAssociateEntrys());
}
/**
* Evaluate equality of fields between the two provided responses
* This method is provided for flexibility for non-standard flows and where context is not used
*/
public void evaluateEquality(Response actualResponse, Response expectedResponse) {
evaluateEquality(extract(actualResponse), extract(expectedResponse));
}
/**
* Evaluate equality of fields between the two provided step label's responses
* This method is provided for flexibility for non-standard flows
*/
public void evaluateEquality(StepLabel stepLabelForActualResponse, StepLabel stepLabelForExpectedResponse) {
evaluateEquality(extract(contactAppContext.getResponseMap().get(stepLabelForActualResponse)), extract(contactAppContext.getResponseMap().get(stepLabelForExpectedResponse)));
}
/**
* Evaluate equality of fields between the provided response and the step label's response
* This method is provided for flexibility for non-standard flows
*/
public void evaluateEquality(Response actualContactResponse, StepLabel stepLabelForExpectedResponse) {
evaluateEquality(extract(actualContactResponse), extract(contactAppContext.getResponseMap().get(stepLabelForExpectedResponse)));
}
/**
* Evaluate equality of fields between the two provided contact responses
* This method is provided for flexibility for non-standard flows and where context is not used
*/
public void evaluateEquality(ContactResponse actualContactResponse, ContactResponse expectedContactResponse) {
evaluateContactFieldsEquality(actualContactResponse, expectedContactResponse.getId(), expectedContactResponse.getDescription(), expectedContactResponse.getContactInfo(), expectedContactResponse.getSomeField(), expectedContactResponse.getKnownAssociateEntrys());
}
private void evaluateContactFieldsEquality(ContactResponse actualContactResponse, String contactId, String description, String contactInfo, String someField, List<KnownAssociateEntry> knownAssociateEntrys) {
assertThat(actualContactResponse.getId()).isEqualTo(contactId);
assertThat(actualContactResponse.getDescription()).isEqualTo(description);
assertThat(actualContactResponse.getContactInfo()).isEqualTo(contactInfo);
assertThat(actualContactResponse.getSomeField()).isEqualTo(someField);
if(actualContactResponse.getKnownAssociateEntrys() == null || actualContactResponse.getKnownAssociateEntrys().isEmpty()) {
assertThat(knownAssociateEntrys == null || knownAssociateEntrys.isEmpty()).isTrue();
} else {
assertThat(actualContactResponse.getKnownAssociateEntrys()).isEqualTo(knownAssociateEntrys);
}
}
/**
* Evaluate difference of fields between the two provided contact responses except creation date time
*/
public void evaluateDifference(ContactResponse actual, ContactResponse expected) {
assertThat(actual.getDescription()).as("Description should be updated").isNotEqualTo(expected.getDescription());
assertThat(actual.getContactInfo()).as("ContactInfo should be updated").isNotEqualTo(expected.getContactInfo());
assertThat(actual.getSomeField()).as("SomeField should be updated").isNotEqualTo(expected.getSomeField());
assertThat(actual.getUpdatedDateTime()).as("UpdatedDateTime should be updated").isNotEqualTo(expected.getUpdatedDateTime());
assertThat(actual.getCreatedDateTime()).as("CreatedDateTime should be the same").isEqualTo(expected.getCreatedDateTime());
}
/**
* Extracts a ContactResponse object from a provided response
* Throws error if no ContactResponse body exists
*/
public ContactResponse extract(Response response) {
return contactAppServiceClient.extractContactResponse(response);
}
/**
* Extracts a ErrorResponse object from a provided response
* Throws error if no ErrorResponse body exists
*/
public ErrorResponse extractError(Response response) {
return contactAppServiceClient.extractErrorResponse(response);
}
/**
* Fetch the relevant contact response from the context, given the provided step label
* This method is provided for flexibility for non-standard flows
*/
public ContactResponse fetchContactResponseFromContext(StepLabel stepLabel) {
return extract(contactAppContext.getResponseMap().get(stepLabel));
}
/**
* Fetch the contact response list from the context (populated using the retrieveAllContacts() function)
* This method is provided for flexibility for non-standard flows
*/
public List<ContactResponse> fetchContactResponseListFromContext() {
return contactAppContext.getContactResponseList();
}
/**
* Clean up any contacts referenced in the context as created
*/
public void deleteAllCreatedContacts() {
for(int i = 0; i < stepLabelCounter.getLatest(CREATION_RESPONSE).number(); i++) {
ContactResponse contactResponse = extract(contactAppContext.getResponseMap().get(new StepLabel(CREATION_RESPONSE, i)));
if(contactResponse.getId() != null) {
contactAppServiceClient.deleteContact(contactResponse.getId());
}
}
}
}
And finally some supporting objects. The StepLabel is used to reference Response objects for out-of-order evaluation, and intended to provide flexibility.
public class StepLabelCounter {
private final Map<String, Integer> stepCountMap = new HashMap<>();
public StepLabel getNext(StepLabel stepLabel) {
return getNext(stepLabel.step());
}
/**
* Increments the step counter and returns the next StepLabel
* Used for storing a new step's data
*/
public StepLabel getNext(String step) {
if(stepCountMap.containsKey(step)) {
return incAndUpdateAndNext(step, stepCountMap.get(step));
} else {
int initialCount = -1;
return incAndUpdateAndNext(step, initialCount);
}
}
private StepLabel incAndUpdateAndNext(String step, int currentCount) {
stepCountMap.put(step, currentCount+1);
return new StepLabel(step, currentCount+1);
}
/**
* Returns the latest StepLabel for the given step
* Used for referencing the last step's data
*/
public StepLabel getLatest(String step) {
return new StepLabel(step, stepCountMap.get(step));
}
}
public record StepLabel(String step, int number) {
public StepLabel(String step) {
this(step, 0);
}
public StepLabel inc() {
return new StepLabel(this.step, number+1);
}
public StepLabel withNumber(int number) {
return new StepLabel(this.step, number);
}
}
@Data
public class ContactAppContext {
public ContactAppContext() {
responseMap = new HashMap<>();
}
private ContactRequest contactRequest;
private Map<StepLabel, Response> responseMap;
private List<ContactResponse> contactResponseList;
}