k6 can test multipart file uploads, but it doesn’t handle them out of the box like a browser.

Let’s see k6 upload a file to a simple Go HTTP server.

First, the Go server. This is a minimal example to receive a file.

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
)

func uploadFile(w http.ResponseWriter, r *http.Request) {
	// Parse the multipart form, with a max memory of 10 MB
	r.ParseMultipartForm(10 << 20) // 10 MB

	// Get the file from the form
	file, handler, err := r.FormFile("myFile")
	if err != nil {
		fmt.Println("Error retrieving the file:", err)
		return
	}
	defer file.Close()

	fmt.Printf("Uploaded File: %+v\n", handler.Filename)
	fmt.Printf("File Size: %d\n", handler.Size)

	// Create a new file on the server
	dst, err := os.Create(filepath.Join("./uploads", handler.Filename))
	if err != nil {
		fmt.Println("Error creating the file on server:", err)
		return
	}
	defer dst.Close()

	// Copy the uploaded file to the created file on the server
	if _, err := io.Copy(dst, file); err != nil {
		fmt.Println("Error copying file content:", err)
		return
	}

	fmt.Println("Successfully Uploaded File:", handler.Filename)
	w.Write([]byte("File uploaded successfully!"))
}

func main() {
	http.HandleFunc("/upload", uploadFile)
	fmt.Println("Server started on :8080")
	http.ListenAndServe(":8080", nil)
}

To run this, save it as server.go, create an uploads directory, and run go run server.go. It will listen on port 8080.

Now, the k6 script. This is where the magic (and the complexity) happens.

import http from 'k6/http';
import { sleep } from 'k6';
import { FormData, FormDataPart } from 'https://jslib.k6.io/form-data/4.0.0/index.js';

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  // Create a new FormData instance
  const fd = new FormData();

  // Create a file part. The first argument is the file content,
  // the second is the field name (must match the server's expected name),
  // and the third is the filename.
  const fileContent = open('./sample.txt', 'b'); // Read file in binary mode
  const filePart = new FormDataPart({
    content: fileContent,
    filename: 'sample.txt',
    fieldName: 'myFile', // This must match the server's form key
    contentType: 'text/plain', // Optional, but good practice
  });

  // Add the file part to the FormData
  fd.append(filePart);

  // You can also add regular form fields if needed
  // fd.append('userId', '12345');

  // Make the HTTP POST request
  const url = 'http://localhost:8080/upload';
  const params = {
    headers: {
      // k6 will automatically set the Content-Type with the boundary
      // 'Content-Type': 'multipart/form-data', // Do NOT set this manually
    },
  };

  const res = http.post(url, fd.toObject(), params);

  // Check the response
  if (res.status !== 200) {
    console.error(`Upload failed: ${res.status} - ${res.body}`);
  }

  sleep(1);
}

Before running this, create a sample.txt file in the same directory as your k6 script.

To run the k6 test, execute k6 run your_script_name.js.

The key to k6 multipart uploads is the jslib.k6.io/form-data library. It provides the FormData and FormDataPart objects that mimic the browser’s FormData API. You create a FormData instance, then create FormDataPart objects for each part of your multipart request. For files, you provide the file content (read in binary mode using open('filename', 'b')), the fieldName (which must match what your server expects, in this case, myFile), and optionally a filename and contentType. Finally, you pass the fd.toObject() to http.post. k6 automatically handles setting the correct Content-Type header with the appropriate boundary string.

The most surprising thing about k6’s file upload testing is that it requires a dedicated library to construct the multipart/form-data payload, unlike simple JSON or form-encoded requests which are built-in. This means you’re not directly manipulating bytes or headers to form the request; you’re using an abstraction that builds it for you.

The Go server receives the request, parses it using r.ParseMultipartForm, and then retrieves the file via r.FormFile("myFile"). The 10 << 20 argument to ParseMultipartForm sets the maximum memory the server will use to store uploaded files before writing them to disk. This is important for preventing denial-of-service attacks where a large file could exhaust server memory. The server then creates a new file in the ./uploads directory and copies the content from the uploaded file to the new file.

The FormDataPart constructor takes an object with content, filename, fieldName, and contentType. The content should be a Uint8Array or similar binary data. open('filename', 'b') in k6 reads the file content as raw bytes, which is precisely what FormDataPart expects for file uploads. The fieldName is crucial; it’s the key the server uses to identify the file within the multipart request. If this doesn’t match, the server won’t find the file.

When http.post(url, fd.toObject(), params) is called, k6 serializes the FormData object into a multipart/form-data request body. It automatically generates a unique boundary string, splits the data into parts separated by this boundary, and sets the Content-Type header to multipart/form-data; boundary=.... The toObject() method is what triggers this serialization.

The params object in http.post is where you’d typically set custom headers. However, for multipart/form-data, you must not manually set the Content-Type header. k6 handles this correctly when you provide the FormData object as the request body. If you set it manually, you’ll likely get errors because k6 won’t be able to match its generated boundary with your manual header.

The open('./sample.txt', 'b') function is key here. The 'b' flag ensures the file is read as binary data (a Uint8Array), which is what the FormDataPart expects for file content. If you omit 'b', it will be read as a string, and the upload will likely fail or result in corrupted data on the server.

After a successful upload, your server will have the sample.txt file (or whatever you named it) in the uploads directory. The k6 script checks for a 200 status code and logs any errors.

The next logical step after testing simple file uploads is to test concurrent uploads or uploads with varying file sizes and types.

Want structured learning?

Take the full K6 course →