Recently I had to test an API contract involving a Multipart File form submission.
The testing framework of choice in this case was Cucumber. I wanted to create two steps:

  1. A step to set the file content and metadata in the form data
    • @And("the Multipart File: {string} with name: {string} and content: {string}")
  2. A step to submit the form data to the API endpoint
    • @Then("the Multipart File is submitted to the API")

Multipart Formdata

For some background I had to refresh my memory of what a Multipart form body looks like when it is constructed, for this I went to Mozilla’s excellent developer docs.
Each file uploaded requires:

  • A Content-Disposition header with the following fields (see Mozilla Developer Network for more info):
    • formdata;
    • name=<parameter_name>
    • filename=<filename>
    • e.g Content-Disposition: formdata; name=myxml; filename=myxml.xml
  • A Content-Type header for your file content type e.g. application/json or application/xml

Cucumber test background

  • Our test fixtures have a “context” that is a ThreadLocal declared like so:
    public static final ThreadLocal<TestContext> context = ThreadLocal.withInitial(TestContext::new);
    
  • This uses the class TestContext to hold any test fixtures between our @Given, @When and @Then steps so we can share context.
    • The TestContext is a lombok’d class for holding data with the @Data annotation.
  • To this I added the formData field in the form of a MultiValueMap which is keyed on string values and holds objects.
    • This represents the key=value pairs that is the main payload of our form data
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.testcontainers.shaded.org.checkerframework.checker.nullness.qual.Nullable;

import java.util.LinkedHashMap;

@Data
@NoArgsConstructor
public class TestContext {
  private ResponseEntity<?> response;

  public void setResponse(ResponseEntity<?> response) {
    this.response = response;
    if (this.response.getBody() != null) {
      if (this.response.getBody() instanceof LinkedHashMap<?, ?> b) {
        this.body = b;
      }
    }
  }

  @Nullable
  private LinkedHashMap<?, ?> body;

  @Nullable
  private MultiValueMap<String, Object> formData;
}

Using Spring’s MockMultipartFile

To put this together we used Spring’s MockMultipartFile to hold file information.

The steps to combine this into a request we can use in our Cucumber steps are:

  1. Retrieve our existing form data from our context, this represents the body of the form we’re going to be submitting.
  2. Construct the MockMultipartFile with our filename, parameter name, content-type and fileContent.
  3. Create the headers for our file upload
    1. Content-Disposition based on the composition outlined prior
      1. name is the parameter name we’re storing the file under
      2. filename is the original name of the file prior to upload
    2. Content-Type to describe the content type of our file.
  4. Finally, we add the form content as a Spring HttpEntity to the form data map under our parameter name (same as our name in the content disposition header)
    1. Both our headers are added as a HttpHeaders instance
    2. The file contents are added as the body of the entity by retrieving the bytes from our MockMultipartFile
  5. We then put the form data back into our context to be used by our RestTemplate later on.

The full declared step looks as follows:

@And("the Multipart File: {string} with name: {string} and content: {string}")
public void setFormParameter(String parameterName, String filename, String fileContent) throws IOException {
    var formData = Optional.ofNullable(context.get().getFormData())
            .orElseGet(LinkedMultiValueMap::new);

    MockMultipartFile metadataXml = new MockMultipartFile(
            filename,
            filename, // The original filename
            "application/xml", // The content type
            fileContent.getBytes() // The content of the file
    );
    // Create entity and add to body
    var headers = new HttpHeaders();
    headers.set("Content-Disposition", String.format("form-data; name=%s; filename=%s", parameterName, metadataXml.getOriginalFilename()));
    headers.set("Content-Type", metadataXml.getContentType());

    formData.set(parameterName, new HttpEntity<>(metadataXml.getBytes(),headers));
    context.get().setFormData(formData);
}

Making the request

We then have another step for submitting the data:

MultiValueMap<String, Object> body = Optional.ofNullable(context.get().getFormData())
        .orElseGet(LinkedMultiValueMap::new);

var httpEntity = new HttpEntity<>(body, getFileUploadHeaders());
var response = restTemplate.exchange("/saml/profile", HttpMethod.PUT, httpEntity, Object.class);
  • getFileUploadHeaders() simply sets the Content-Type header for the whole request to multipart/form-data
  • The RestTemplate is a Spring web client we’re using in our tests to fire off requests.

Notes

It is worth noting when using the MockMultipartFile that MockMultipartFile.getName() is the name of the parameter not the name of the file, use MockMultipartFile.getOriginalFilename() instead.