diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml
index 5625e531fe..7d19b4bbe8 100644
--- a/cucumber-bom/pom.xml
+++ b/cucumber-bom/pom.xml
@@ -18,8 +18,8 @@
         <gherkin.version>28.0.0</gherkin.version>
         <html-formatter.version>21.3.1</html-formatter.version>
         <junit-xml-formatter.version>0.4.0</junit-xml-formatter.version>
-        <messages.version>24.1.0</messages.version>
-        <query.version>12.1.2</query.version>
+        <messages.version>24.1.1-SNAPSHOT</messages.version>
+        <query.version>12.1.3-SNAPSHOT</query.version>
         <tag-expressions.version>6.1.0</tag-expressions.version>
         <testng-xml-formatter.version>0.1.0</testng-xml-formatter.version>
     </properties>
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/CucumberJvmJson.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/CucumberJvmJson.java
new file mode 100644
index 0000000000..0f1504ddeb
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/CucumberJvmJson.java
@@ -0,0 +1,394 @@
+package io.cucumber.core.plugin;
+
+import java.util.List;
+import java.util.Objects;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Object representation of <a href="https://github.com/cucumber/cucumber-json-schema/blob/main/schemas/cucumber-jvm.json">cucumber-jvm.json</a> schema.
+ */
+class CucumberJvmJson {
+    enum JvmElementType {
+        background, scenario
+    }
+    enum JvmStatus {
+        passed,
+        failed,
+        skipped,
+        undefined,
+        pending
+    }
+
+    static class JvmFeature {
+        private final String uri;
+        private final String id;
+        private final Long line;
+        private final String keyword;
+        private final String name;
+        private final String description;
+        private final List<JvmElement> elements;
+        private final List<JvmLocationTag> tags;
+
+        JvmFeature(String uri, String id, Long line, String keyword, String name, String description, List<JvmElement> elements, List<JvmLocationTag> tags) {
+            this.uri = requireNonNull(uri);
+            this.id = requireNonNull(id);
+            this.line = requireNonNull(line);
+            this.keyword = requireNonNull(keyword);
+            this.name = requireNonNull(name);
+            this.description = requireNonNull(description);
+            this.elements = requireNonNull(elements);
+            this.tags = tags;
+        }
+
+        public String getUri() {
+            return uri;
+        }
+
+        public String getId() {
+            return id;
+        }
+
+        public Long getLine() {
+            return line;
+        }
+
+        public String getKeyword() {
+            return keyword;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public List<JvmElement> getElements() {
+            return elements;
+        }
+
+        public List<JvmLocationTag> getTags() {
+            return tags;
+        }
+    }
+
+    static class JvmElement {
+        private final String start_timestamp;
+        private final Long line;
+        private final String id;
+        private final JvmElementType type;
+        private final String keyword;
+        private final String name;
+        private final String description;
+        private final List<JvmStep> steps;
+        private final List<JvmHook> before;
+        private final List<JvmHook> after;
+        private final List<JvmTag> tags;
+
+        JvmElement(String start_timestamp, Long line, String id, JvmElementType type, String keyword, String name, String description, List<JvmStep> steps, List<JvmHook> before, List<JvmHook> after, List<JvmTag> tags) {
+            this.start_timestamp = start_timestamp;
+            this.line = requireNonNull(line);
+            this.id = id;
+            this.type = requireNonNull(type);
+            this.keyword = requireNonNull(keyword);
+            this.name = requireNonNull(name);
+            this.description = requireNonNull(description);
+            this.steps = requireNonNull(steps);
+            this.before = before;
+            this.after = after;
+            this.tags = tags;
+        }
+
+        public String getStart_timestamp() {
+            return start_timestamp;
+        }
+
+        public Long getLine() {
+            return line;
+        }
+
+        public String getId() {
+            return id;
+        }
+
+        public JvmElementType getType() {
+            return type;
+        }
+
+        public String getKeyword() {
+            return keyword;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public List<JvmStep> getSteps() {
+            return steps;
+        }
+
+        public List<JvmHook> getBefore() {
+            return before;
+        }
+
+        public List<JvmHook> getAfter() {
+            return after;
+        }
+
+        public List<JvmTag> getTags() {
+            return tags;
+        }
+    }
+
+    static class JvmStep {
+        private final String keyword;
+        private final Long line;
+        private final JvmMatch match;
+        private final String name;
+        private final JvmResult result;
+        private final JvmDocString doc_string;
+        private final List<JvmDataTableRow> rows;
+
+        JvmStep(String keyword, Long line, JvmMatch match, String name, JvmResult result, JvmDocString doc_string, List<JvmDataTableRow> rows) {
+            this.keyword = requireNonNull(keyword);
+            this.line = requireNonNull(line);
+            this.match = match;
+            this.name = requireNonNull(name);
+            this.result = requireNonNull(result);
+            this.doc_string = doc_string;
+            this.rows = rows;
+        }
+
+        public String getKeyword() {
+            return keyword;
+        }
+
+        public Long getLine() {
+            return line;
+        }
+
+        public JvmMatch getMatch() {
+            return match;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public JvmResult getResult() {
+            return result;
+        }
+
+        public JvmDocString getDoc_string() {
+            return doc_string;
+        }
+
+        public List<JvmDataTableRow> getRows() {
+            return rows;
+        }
+    }
+
+    static class JvmMatch {
+        private final String location;
+        private final List<JvmArgument> arguments;
+
+        JvmMatch(String location, List<JvmArgument> arguments) {
+            this.location = location;
+            this.arguments = arguments;
+        }
+
+        public String getLocation() {
+            return location;
+        }
+
+        public List<JvmArgument> getArguments() {
+            return arguments;
+        }
+    }
+
+    static class JvmArgument {
+        private final String val;
+        private final Number offset;
+
+        JvmArgument(String val, Number offset) {
+            this.val = requireNonNull(val);
+            this.offset = requireNonNull(offset);
+        }
+
+        public String getVal() {
+            return val;
+        }
+
+        public Number getOffset() {
+            return offset;
+        }
+    }
+
+    static class JvmResult {
+        private final Long duration;
+        private final JvmStatus status;
+        private final String error_message;
+
+        JvmResult(Long duration, JvmStatus status, String error_message) {
+            this.duration = duration;
+            this.status = requireNonNull(status);
+            this.error_message = error_message;
+        }
+
+        public Long getDuration() {
+            return duration;
+        }
+
+        public JvmStatus getStatus() {
+            return status;
+        }
+
+        public String getError_message() {
+            return error_message;
+        }
+    }
+
+    static class JvmDocString {
+        private final Long line;
+        private final String value;
+        private final String content_type;
+
+        JvmDocString(Long line, String value, String content_type) {
+            this.line = requireNonNull(line);
+            this.value = requireNonNull(value);
+            this.content_type = content_type;
+        }
+
+        public Long getLine() {
+            return line;
+        }
+
+        public String getValue() {
+            return value;
+        }
+
+        public String getContent_type() {
+            return content_type;
+        }
+    }
+
+    static class JvmDataTableRow {
+        private final List<String> cells;
+
+        JvmDataTableRow(List<String> cells) {
+            this.cells = requireNonNull(cells);
+        }
+
+        public List<String> getCells() {
+            return cells;
+        }
+    }
+
+    static class JvmHook {
+        private final JvmMatch match;
+        private final JvmResult result;
+        private final List<JvmEmbedding> embeddings;
+
+        JvmHook(JvmMatch match, JvmResult result, List<JvmEmbedding> embeddings) {
+            this.match = requireNonNull(match);
+            this.result = requireNonNull(result);
+            this.embeddings = embeddings;
+        }
+
+        public JvmMatch getMatch() {
+            return match;
+        }
+
+        public JvmResult getResult() {
+            return result;
+        }
+
+        public List<JvmEmbedding> getEmbeddings() {
+            return embeddings;
+        }
+    }
+
+    static class JvmEmbedding {
+        private final String mime_type;
+        private final String data;
+        private final String name;
+
+        JvmEmbedding(String mime_type, String data, String name) {
+            this.mime_type = requireNonNull(mime_type);
+            this.data = requireNonNull(data);
+            this.name = name;
+        }
+
+        public String getData() {
+            return data;
+        }
+
+        public String getMime_type() {
+            return mime_type;
+        }
+
+        public String getName() {
+            return name;
+        }
+    }
+
+    static class JvmTag {
+        private final String name;
+
+        JvmTag(String name) {
+            this.name = requireNonNull(name);
+        }
+
+        public String getName() {
+            return name;
+        }
+    }
+
+    static class JvmLocationTag {
+        private final String name;
+        private final String type;
+        private final JvmLocation location;
+
+        JvmLocationTag(String name, String type, JvmLocation location) {
+            this.name = requireNonNull(name);
+            this.type = requireNonNull(type);
+            this.location = requireNonNull(location);
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getType() {
+            return type;
+        }
+
+        public JvmLocation getLocation() {
+            return location;
+        }
+    }
+
+    static class JvmLocation {
+        private final Long line;
+        private final Long column;
+
+        JvmLocation(Long line, Long column) {
+            this.line = requireNonNull(line);
+            this.column = requireNonNull(column);
+        }
+
+        public Long getLine() {
+            return line;
+        }
+
+        public Long getColumn() {
+            return column;
+        }
+    }
+}
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/IdNamingVisitor.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/IdNamingVisitor.java
new file mode 100644
index 0000000000..00e8f81f05
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/IdNamingVisitor.java
@@ -0,0 +1,42 @@
+package io.cucumber.core.plugin;
+
+import io.cucumber.messages.types.Examples;
+import io.cucumber.messages.types.Feature;
+import io.cucumber.messages.types.Pickle;
+import io.cucumber.messages.types.Rule;
+import io.cucumber.messages.types.Scenario;
+import io.cucumber.query.NamingStrategy;
+
+import static io.cucumber.core.plugin.TestSourcesModel.convertToId;
+
+class IdNamingVisitor implements NamingStrategy.NamingVisitor {
+    @Override
+    public String accept(Feature feature) {
+        return convertToId(feature.getName());
+    }
+
+    @Override
+    public String accept(Rule rule) {
+        return convertToId(rule.getName());
+    }
+
+    @Override
+    public String accept(Scenario scenario) {
+        return convertToId(scenario.getName());
+    }
+
+    @Override
+    public String accept(Examples examples) {
+        return convertToId(examples.getName());
+    }
+
+    @Override
+    public String accept(int examplesIndex, int exampleIndex) {
+        return (examplesIndex + 1) + ";" + (examplesIndex + 1);
+    }
+
+    @Override
+    public String accept(Pickle pickle) {
+        return convertToId(pickle.getName());
+    }
+}
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatter.java
index f36526e4ea..922929b0a6 100644
--- a/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatter.java
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatter.java
@@ -1,442 +1,41 @@
 package io.cucumber.core.plugin;
 
-import io.cucumber.messages.types.Background;
-import io.cucumber.messages.types.Feature;
-import io.cucumber.messages.types.Scenario;
-import io.cucumber.messages.types.Step;
+import io.cucumber.messages.types.Envelope;
+import io.cucumber.plugin.ConcurrentEventListener;
 import io.cucumber.plugin.EventListener;
-import io.cucumber.plugin.event.Argument;
-import io.cucumber.plugin.event.DataTableArgument;
-import io.cucumber.plugin.event.DocStringArgument;
-import io.cucumber.plugin.event.EmbedEvent;
 import io.cucumber.plugin.event.EventPublisher;
-import io.cucumber.plugin.event.HookTestStep;
-import io.cucumber.plugin.event.HookType;
-import io.cucumber.plugin.event.PickleStepTestStep;
-import io.cucumber.plugin.event.Result;
-import io.cucumber.plugin.event.Status;
-import io.cucumber.plugin.event.StepArgument;
-import io.cucumber.plugin.event.TestCase;
-import io.cucumber.plugin.event.TestCaseStarted;
-import io.cucumber.plugin.event.TestRunFinished;
-import io.cucumber.plugin.event.TestSourceRead;
-import io.cucumber.plugin.event.TestStep;
-import io.cucumber.plugin.event.TestStepFinished;
-import io.cucumber.plugin.event.TestStepStarted;
-import io.cucumber.plugin.event.WriteEvent;
 
 import java.io.IOException;
 import java.io.OutputStream;
-import java.io.Writer;
-import java.net.URI;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
 
-import static io.cucumber.core.exception.ExceptionUtils.printStackTrace;
-import static io.cucumber.core.plugin.TestSourcesModel.getBackgroundForTestCase;
-import static java.util.Collections.singletonList;
-import static java.util.Locale.ROOT;
-import static java.util.stream.Collectors.toList;
+public final class JsonFormatter implements ConcurrentEventListener {
 
-public final class JsonFormatter implements EventListener {
+    private final MessagesToJsonWriter writer;
 
-    private static final String before = "before";
-    private static final String after = "after";
-    private final List<Map<String, Object>> featureMaps = new ArrayList<>();
-    private final Map<String, Object> currentBeforeStepHookList = new HashMap<>();
-    private final Writer writer;
-    private final TestSourcesModel testSources = new TestSourcesModel();
-    private URI currentFeatureFile;
-    private List<Map<String, Object>> currentElementsList;
-    private Map<String, Object> currentElementMap;
-    private Map<String, Object> currentTestCaseMap;
-    private List<Map<String, Object>> currentStepsList;
-    private Map<String, Object> currentStepOrHookMap;
-
-    @SuppressWarnings("WeakerAccess") // Used by PluginFactory
     public JsonFormatter(OutputStream out) {
-        this.writer = new UTF8OutputStreamWriter(out);
+        this.writer = new MessagesToJsonWriter(out, Jackson.OBJECT_MAPPER::writeValue);
     }
 
     @Override
     public void setEventPublisher(EventPublisher publisher) {
-        publisher.registerHandlerFor(TestSourceRead.class, this::handleTestSourceRead);
-        publisher.registerHandlerFor(TestCaseStarted.class, this::handleTestCaseStarted);
-        publisher.registerHandlerFor(TestStepStarted.class, this::handleTestStepStarted);
-        publisher.registerHandlerFor(TestStepFinished.class, this::handleTestStepFinished);
-        publisher.registerHandlerFor(WriteEvent.class, this::handleWrite);
-        publisher.registerHandlerFor(EmbedEvent.class, this::handleEmbed);
-        publisher.registerHandlerFor(TestRunFinished.class, this::finishReport);
-    }
-
-    private void handleTestSourceRead(TestSourceRead event) {
-        testSources.addTestSourceReadEvent(event.getUri(), event);
-    }
-
-    @SuppressWarnings("unchecked")
-    private void handleTestCaseStarted(TestCaseStarted event) {
-        if (currentFeatureFile == null || !currentFeatureFile.equals(event.getTestCase().getUri())) {
-            currentFeatureFile = event.getTestCase().getUri();
-            Map<String, Object> currentFeatureMap = createFeatureMap(event.getTestCase());
-            featureMaps.add(currentFeatureMap);
-            currentElementsList = (List<Map<String, Object>>) currentFeatureMap.get("elements");
-        }
-        currentTestCaseMap = createTestCase(event);
-        if (testSources.hasBackground(currentFeatureFile, event.getTestCase().getLocation().getLine())) {
-            currentElementMap = createBackground(event.getTestCase());
-            currentElementsList.add(currentElementMap);
-        } else {
-            currentElementMap = currentTestCaseMap;
-        }
-        currentElementsList.add(currentTestCaseMap);
-        currentStepsList = (List<Map<String, Object>>) currentElementMap.get("steps");
-    }
-
-    @SuppressWarnings("unchecked")
-    private void handleTestStepStarted(TestStepStarted event) {
-        if (event.getTestStep() instanceof PickleStepTestStep) {
-            PickleStepTestStep testStep = (PickleStepTestStep) event.getTestStep();
-            if (isFirstStepAfterBackground(testStep)) {
-                currentElementMap = currentTestCaseMap;
-                currentStepsList = (List<Map<String, Object>>) currentElementMap.get("steps");
-            }
-            currentStepOrHookMap = createTestStep(testStep);
-            // add beforeSteps list to current step
-            if (currentBeforeStepHookList.containsKey(before)) {
-                currentStepOrHookMap.put(before, currentBeforeStepHookList.get(before));
-                currentBeforeStepHookList.clear();
-            }
-            currentStepsList.add(currentStepOrHookMap);
-        } else if (event.getTestStep() instanceof HookTestStep) {
-            HookTestStep hookTestStep = (HookTestStep) event.getTestStep();
-            currentStepOrHookMap = createHookStep(hookTestStep);
-            addHookStepToTestCaseMap(currentStepOrHookMap, hookTestStep.getHookType());
-        } else {
-            throw new IllegalStateException();
-        }
-    }
-
-    private void handleTestStepFinished(TestStepFinished event) {
-        currentStepOrHookMap.put("match", createMatchMap(event.getTestStep(), event.getResult()));
-        currentStepOrHookMap.put("result", createResultMap(event.getResult()));
+        publisher.registerHandlerFor(Envelope.class, this::write);
     }
 
-    private void handleWrite(WriteEvent event) {
-        addOutputToHookMap(event.getText());
-    }
-
-    private void handleEmbed(EmbedEvent event) {
-        addEmbeddingToHookMap(event.getData(), event.getMediaType(), event.getName());
-    }
-
-    private void finishReport(TestRunFinished event) {
-        Throwable exception = event.getResult().getError();
-        if (exception != null) {
-            featureMaps.add(createDummyFeatureForFailure(event));
-        }
-
+    private void write(Envelope event) {
         try {
-            Jackson.OBJECT_MAPPER.writeValue(writer, featureMaps);
-            writer.close();
+            writer.write(event);
         } catch (IOException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    private Map<String, Object> createFeatureMap(TestCase testCase) {
-        Map<String, Object> featureMap = new HashMap<>();
-        featureMap.put("uri", TestSourcesModel.relativize(testCase.getUri()));
-        featureMap.put("elements", new ArrayList<Map<String, Object>>());
-        Feature feature = testSources.getFeature(testCase.getUri());
-        if (feature != null) {
-            featureMap.put("keyword", feature.getKeyword());
-            featureMap.put("name", feature.getName());
-            featureMap.put("description", feature.getDescription() != null ? feature.getDescription() : "");
-            featureMap.put("line", feature.getLocation().getLine());
-            featureMap.put("id", TestSourcesModel.convertToId(feature.getName()));
-            featureMap.put("tags", feature.getTags().stream().map(
-                tag -> {
-                    Map<String, Object> json = new LinkedHashMap<>();
-                    json.put("name", tag.getName());
-                    json.put("type", "Tag");
-                    Map<String, Object> location = new LinkedHashMap<>();
-                    location.put("line", tag.getLocation().getLine());
-                    location.put("column", tag.getLocation().getColumn());
-                    json.put("location", location);
-                    return json;
-                }).collect(toList()));
-
-        }
-        return featureMap;
-    }
-
-    private Map<String, Object> createTestCase(TestCaseStarted event) {
-        Map<String, Object> testCaseMap = new HashMap<>();
-
-        testCaseMap.put("start_timestamp", getDateTimeFromTimeStamp(event.getInstant()));
-
-        TestCase testCase = event.getTestCase();
-
-        testCaseMap.put("name", testCase.getName());
-        testCaseMap.put("line", testCase.getLine());
-        testCaseMap.put("type", "scenario");
-        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testCase.getLine());
-        if (astNode != null) {
-            testCaseMap.put("id", TestSourcesModel.calculateId(astNode));
-            Scenario scenarioDefinition = TestSourcesModel.getScenarioDefinition(astNode);
-            testCaseMap.put("keyword", scenarioDefinition.getKeyword());
-            testCaseMap.put("description",
-                scenarioDefinition.getDescription() != null ? scenarioDefinition.getDescription() : "");
-        }
-        testCaseMap.put("steps", new ArrayList<Map<String, Object>>());
-        if (!testCase.getTags().isEmpty()) {
-            List<Map<String, Object>> tagList = new ArrayList<>();
-            for (String tag : testCase.getTags()) {
-                Map<String, Object> tagMap = new HashMap<>();
-                tagMap.put("name", tag);
-                tagList.add(tagMap);
-            }
-            testCaseMap.put("tags", tagList);
-        }
-        return testCaseMap;
-    }
-
-    private Map<String, Object> createBackground(TestCase testCase) {
-        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testCase.getLocation().getLine());
-        if (astNode != null) {
-            Background background = getBackgroundForTestCase(astNode).get();
-            Map<String, Object> testCaseMap = new HashMap<>();
-            testCaseMap.put("name", background.getName());
-            testCaseMap.put("line", background.getLocation().getLine());
-            testCaseMap.put("type", "background");
-            testCaseMap.put("keyword", background.getKeyword());
-            testCaseMap.put("description", background.getDescription() != null ? background.getDescription() : "");
-            testCaseMap.put("steps", new ArrayList<Map<String, Object>>());
-            return testCaseMap;
-        }
-        return null;
-    }
-
-    private boolean isFirstStepAfterBackground(PickleStepTestStep testStep) {
-        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testStep.getStepLine());
-        if (astNode == null) {
-            return false;
-        }
-        return currentElementMap != currentTestCaseMap && !TestSourcesModel.isBackgroundStep(astNode);
-    }
-
-    private Map<String, Object> createTestStep(PickleStepTestStep testStep) {
-        Map<String, Object> stepMap = new HashMap<>();
-        stepMap.put("name", testStep.getStepText());
-        stepMap.put("line", testStep.getStepLine());
-        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testStep.getStepLine());
-        StepArgument argument = testStep.getStepArgument();
-        if (argument != null) {
-            if (argument instanceof DocStringArgument) {
-                DocStringArgument docStringArgument = (DocStringArgument) argument;
-                stepMap.put("doc_string", createDocStringMap(docStringArgument));
-            } else if (argument instanceof DataTableArgument) {
-                DataTableArgument dataTableArgument = (DataTableArgument) argument;
-                stepMap.put("rows", createDataTableList(dataTableArgument));
-            }
-        }
-        if (astNode != null) {
-            Step step = (Step) astNode.node;
-            stepMap.put("keyword", step.getKeyword());
+            throw new IllegalStateException(e);
         }
 
-        return stepMap;
-    }
-
-    private Map<String, Object> createHookStep(HookTestStep hookTestStep) {
-        return new HashMap<>();
-    }
-
-    private void addHookStepToTestCaseMap(Map<String, Object> currentStepOrHookMap, HookType hookType) {
-        String hookName;
-        if (hookType == HookType.AFTER || hookType == HookType.AFTER_STEP)
-            hookName = after;
-        else
-            hookName = before;
-
-        Map<String, Object> mapToAddTo;
-        switch (hookType) {
-            case BEFORE:
-                mapToAddTo = currentTestCaseMap;
-                break;
-            case AFTER:
-                mapToAddTo = currentTestCaseMap;
-                break;
-            case BEFORE_STEP:
-                mapToAddTo = currentBeforeStepHookList;
-                break;
-            case AFTER_STEP:
-                mapToAddTo = currentStepsList.get(currentStepsList.size() - 1);
-                break;
-            default:
-                mapToAddTo = currentTestCaseMap;
-        }
-
-        if (!mapToAddTo.containsKey(hookName)) {
-            mapToAddTo.put(hookName, new ArrayList<Map<String, Object>>());
-        }
-        ((List<Map<String, Object>>) mapToAddTo.get(hookName)).add(currentStepOrHookMap);
-    }
-
-    private Map<String, Object> createMatchMap(TestStep step, Result result) {
-        Map<String, Object> matchMap = new HashMap<>();
-        if (step instanceof PickleStepTestStep) {
-            PickleStepTestStep testStep = (PickleStepTestStep) step;
-            if (!testStep.getDefinitionArgument().isEmpty()) {
-                List<Map<String, Object>> argumentList = new ArrayList<>();
-                for (Argument argument : testStep.getDefinitionArgument()) {
-                    Map<String, Object> argumentMap = new HashMap<>();
-                    if (argument.getValue() != null) {
-                        argumentMap.put("val", argument.getValue());
-                        argumentMap.put("offset", argument.getStart());
-                    }
-                    argumentList.add(argumentMap);
-                }
-                matchMap.put("arguments", argumentList);
+        // TODO: Plugins should implement the closable interface
+        // and be closed by Cucumber
+        if (event.getTestRunFinished().isPresent()) {
+            try {
+                writer.close();
+            } catch (IOException e) {
+                throw new IllegalStateException(e);
             }
         }
-        if (!result.getStatus().is(Status.UNDEFINED)) {
-            matchMap.put("location", step.getCodeLocation());
-        }
-        return matchMap;
     }
-
-    private Map<String, Object> createResultMap(Result result) {
-        Map<String, Object> resultMap = new HashMap<>();
-        resultMap.put("status", result.getStatus().name().toLowerCase(ROOT));
-        if (result.getError() != null) {
-            resultMap.put("error_message", printStackTrace(result.getError()));
-        }
-        if (!result.getDuration().isZero()) {
-            resultMap.put("duration", result.getDuration().toNanos());
-        }
-        return resultMap;
-    }
-
-    private void addOutputToHookMap(String text) {
-        if (!currentStepOrHookMap.containsKey("output")) {
-            currentStepOrHookMap.put("output", new ArrayList<String>());
-        }
-        ((List<String>) currentStepOrHookMap.get("output")).add(text);
-    }
-
-    private void addEmbeddingToHookMap(byte[] data, String mediaType, String name) {
-        if (!currentStepOrHookMap.containsKey("embeddings")) {
-            currentStepOrHookMap.put("embeddings", new ArrayList<Map<String, Object>>());
-        }
-        Map<String, Object> embedMap = createEmbeddingMap(data, mediaType, name);
-        ((List<Map<String, Object>>) currentStepOrHookMap.get("embeddings")).add(embedMap);
-    }
-
-    private Map<String, Object> createDummyFeatureForFailure(TestRunFinished event) {
-        Throwable exception = event.getResult().getError();
-
-        Map<String, Object> feature = new LinkedHashMap<>();
-        feature.put("line", 1);
-        {
-            Map<String, Object> scenario = new LinkedHashMap<>();
-            feature.put("elements", singletonList(scenario));
-
-            scenario.put("start_timestamp", getDateTimeFromTimeStamp(event.getInstant()));
-            scenario.put("line", 2);
-            scenario.put("name", "Failure while executing Cucumber");
-            scenario.put("description", "");
-            scenario.put("id", "failure;failure-while-executing-cucumber");
-            scenario.put("type", "scenario");
-            scenario.put("keyword", "Scenario");
-
-            Map<String, Object> when = new LinkedHashMap<>();
-            Map<String, Object> then = new LinkedHashMap<>();
-            scenario.put("steps", Arrays.asList(when, then));
-            {
-
-                {
-                    Map<String, Object> whenResult = new LinkedHashMap<>();
-                    when.put("result", whenResult);
-                    whenResult.put("duration", 0);
-                    whenResult.put("status", "passed");
-                }
-                when.put("line", 3);
-                when.put("name", "Cucumber failed while executing");
-                Map<String, Object> whenMatch = new LinkedHashMap<>();
-                when.put("match", whenMatch);
-                whenMatch.put("arguments", new ArrayList<>());
-                whenMatch.put("location", "io.cucumber.core.Failure.failure_while_executing_cucumber()");
-                when.put("keyword", "When ");
-
-                {
-                    Map<String, Object> thenResult = new LinkedHashMap<>();
-                    then.put("result", thenResult);
-                    thenResult.put("duration", 0);
-                    thenResult.put("error_message", printStackTrace(exception));
-                    thenResult.put("status", "failed");
-                }
-                then.put("line", 4);
-                then.put("name", "Cucumber will report this error:");
-                Map<String, Object> thenMatch = new LinkedHashMap<>();
-                then.put("match", thenMatch);
-                thenMatch.put("arguments", new ArrayList<>());
-                thenMatch.put("location", "io.cucumber.core.Failure.cucumber_reports_this_error()");
-                then.put("keyword", "Then ");
-            }
-
-            feature.put("name", "Test run failed");
-            feature.put("description", "There were errors during the execution");
-            feature.put("id", "failure");
-            feature.put("keyword", "Feature");
-            feature.put("uri", "classpath:io/cucumber/core/failure.feature");
-            feature.put("tags", new ArrayList<>());
-        }
-
-        return feature;
-    }
-
-    private String getDateTimeFromTimeStamp(Instant instant) {
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
-                .withZone(ZoneOffset.UTC);
-        return formatter.format(instant);
-    }
-
-    private Map<String, Object> createDocStringMap(DocStringArgument docString) {
-        Map<String, Object> docStringMap = new HashMap<>();
-        docStringMap.put("value", docString.getContent());
-        docStringMap.put("line", docString.getLine());
-        docStringMap.put("content_type", docString.getMediaType());
-        return docStringMap;
-    }
-
-    private List<Map<String, List<String>>> createDataTableList(DataTableArgument argument) {
-        List<Map<String, List<String>>> rowList = new ArrayList<>();
-        for (List<String> row : argument.cells()) {
-            Map<String, List<String>> rowMap = new HashMap<>();
-            rowMap.put("cells", new ArrayList<>(row));
-            rowList.add(rowMap);
-        }
-        return rowList;
-    }
-
-    private Map<String, Object> createEmbeddingMap(byte[] data, String mediaType, String name) {
-        Map<String, Object> embedMap = new HashMap<>();
-        embedMap.put("mime_type", mediaType); // Should be media-type but not
-                                              // worth migrating for
-        embedMap.put("data", Base64.getEncoder().encodeToString(data));
-        if (name != null) {
-            embedMap.put("name", name);
-        }
-        return embedMap;
-    }
-
 }
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatterOld.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatterOld.java
new file mode 100644
index 0000000000..8b6d943480
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonFormatterOld.java
@@ -0,0 +1,442 @@
+package io.cucumber.core.plugin;
+
+import io.cucumber.messages.types.Background;
+import io.cucumber.messages.types.Feature;
+import io.cucumber.messages.types.Scenario;
+import io.cucumber.messages.types.Step;
+import io.cucumber.plugin.EventListener;
+import io.cucumber.plugin.event.Argument;
+import io.cucumber.plugin.event.DataTableArgument;
+import io.cucumber.plugin.event.DocStringArgument;
+import io.cucumber.plugin.event.EmbedEvent;
+import io.cucumber.plugin.event.EventPublisher;
+import io.cucumber.plugin.event.HookTestStep;
+import io.cucumber.plugin.event.HookType;
+import io.cucumber.plugin.event.PickleStepTestStep;
+import io.cucumber.plugin.event.Result;
+import io.cucumber.plugin.event.Status;
+import io.cucumber.plugin.event.StepArgument;
+import io.cucumber.plugin.event.TestCase;
+import io.cucumber.plugin.event.TestCaseStarted;
+import io.cucumber.plugin.event.TestRunFinished;
+import io.cucumber.plugin.event.TestSourceRead;
+import io.cucumber.plugin.event.TestStep;
+import io.cucumber.plugin.event.TestStepFinished;
+import io.cucumber.plugin.event.TestStepStarted;
+import io.cucumber.plugin.event.WriteEvent;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Writer;
+import java.net.URI;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static io.cucumber.core.exception.ExceptionUtils.printStackTrace;
+import static io.cucumber.core.plugin.TestSourcesModel.getBackgroundForTestCase;
+import static java.util.Collections.singletonList;
+import static java.util.Locale.ROOT;
+import static java.util.stream.Collectors.toList;
+
+public final class JsonFormatterOld implements EventListener {
+
+    private static final String before = "before";
+    private static final String after = "after";
+    private final List<Map<String, Object>> featureMaps = new ArrayList<>();
+    private final Map<String, Object> currentBeforeStepHookList = new HashMap<>();
+    private final Writer writer;
+    private final TestSourcesModel testSources = new TestSourcesModel();
+    private URI currentFeatureFile;
+    private List<Map<String, Object>> currentElementsList;
+    private Map<String, Object> currentElementMap;
+    private Map<String, Object> currentTestCaseMap;
+    private List<Map<String, Object>> currentStepsList;
+    private Map<String, Object> currentStepOrHookMap;
+
+    @SuppressWarnings("WeakerAccess") // Used by PluginFactory
+    public JsonFormatterOld(OutputStream out) {
+        this.writer = new UTF8OutputStreamWriter(out);
+    }
+
+    @Override
+    public void setEventPublisher(EventPublisher publisher) {
+        publisher.registerHandlerFor(TestSourceRead.class, this::handleTestSourceRead);
+        publisher.registerHandlerFor(TestCaseStarted.class, this::handleTestCaseStarted);
+        publisher.registerHandlerFor(TestStepStarted.class, this::handleTestStepStarted);
+        publisher.registerHandlerFor(TestStepFinished.class, this::handleTestStepFinished);
+        publisher.registerHandlerFor(WriteEvent.class, this::handleWrite);
+        publisher.registerHandlerFor(EmbedEvent.class, this::handleEmbed);
+        publisher.registerHandlerFor(TestRunFinished.class, this::finishReport);
+    }
+
+    private void handleTestSourceRead(TestSourceRead event) {
+        testSources.addTestSourceReadEvent(event.getUri(), event);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void handleTestCaseStarted(TestCaseStarted event) {
+        if (currentFeatureFile == null || !currentFeatureFile.equals(event.getTestCase().getUri())) {
+            currentFeatureFile = event.getTestCase().getUri();
+            Map<String, Object> currentFeatureMap = createFeatureMap(event.getTestCase());
+            featureMaps.add(currentFeatureMap);
+            currentElementsList = (List<Map<String, Object>>) currentFeatureMap.get("elements");
+        }
+        currentTestCaseMap = createTestCase(event);
+        if (testSources.hasBackground(currentFeatureFile, event.getTestCase().getLocation().getLine())) {
+            currentElementMap = createBackground(event.getTestCase());
+            currentElementsList.add(currentElementMap);
+        } else {
+            currentElementMap = currentTestCaseMap;
+        }
+        currentElementsList.add(currentTestCaseMap);
+        currentStepsList = (List<Map<String, Object>>) currentElementMap.get("steps");
+    }
+
+    @SuppressWarnings("unchecked")
+    private void handleTestStepStarted(TestStepStarted event) {
+        if (event.getTestStep() instanceof PickleStepTestStep) {
+            PickleStepTestStep testStep = (PickleStepTestStep) event.getTestStep();
+            if (isFirstStepAfterBackground(testStep)) {
+                currentElementMap = currentTestCaseMap;
+                currentStepsList = (List<Map<String, Object>>) currentElementMap.get("steps");
+            }
+            currentStepOrHookMap = createTestStep(testStep);
+            // add beforeSteps list to current step
+            if (currentBeforeStepHookList.containsKey(before)) {
+                currentStepOrHookMap.put(before, currentBeforeStepHookList.get(before));
+                currentBeforeStepHookList.clear();
+            }
+            currentStepsList.add(currentStepOrHookMap);
+        } else if (event.getTestStep() instanceof HookTestStep) {
+            HookTestStep hookTestStep = (HookTestStep) event.getTestStep();
+            currentStepOrHookMap = createHookStep(hookTestStep);
+            addHookStepToTestCaseMap(currentStepOrHookMap, hookTestStep.getHookType());
+        } else {
+            throw new IllegalStateException();
+        }
+    }
+
+    private void handleTestStepFinished(TestStepFinished event) {
+        currentStepOrHookMap.put("match", createMatchMap(event.getTestStep(), event.getResult()));
+        currentStepOrHookMap.put("result", createResultMap(event.getResult()));
+    }
+
+    private void handleWrite(WriteEvent event) {
+        addOutputToHookMap(event.getText());
+    }
+
+    private void handleEmbed(EmbedEvent event) {
+        addEmbeddingToHookMap(event.getData(), event.getMediaType(), event.getName());
+    }
+
+    private void finishReport(TestRunFinished event) {
+        Throwable exception = event.getResult().getError();
+        if (exception != null) {
+            featureMaps.add(createDummyFeatureForFailure(event));
+        }
+
+        try {
+            Jackson.OBJECT_MAPPER.writeValue(writer, featureMaps);
+            writer.close();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private Map<String, Object> createFeatureMap(TestCase testCase) {
+        Map<String, Object> featureMap = new HashMap<>();
+        featureMap.put("uri", TestSourcesModel.relativize(testCase.getUri()));
+        featureMap.put("elements", new ArrayList<Map<String, Object>>());
+        Feature feature = testSources.getFeature(testCase.getUri());
+        if (feature != null) {
+            featureMap.put("keyword", feature.getKeyword());
+            featureMap.put("name", feature.getName());
+            featureMap.put("description", feature.getDescription() != null ? feature.getDescription() : "");
+            featureMap.put("line", feature.getLocation().getLine());
+            featureMap.put("id", TestSourcesModel.convertToId(feature.getName()));
+            featureMap.put("tags", feature.getTags().stream().map(
+                tag -> {
+                    Map<String, Object> json = new LinkedHashMap<>();
+                    json.put("name", tag.getName());
+                    json.put("type", "Tag");
+                    Map<String, Object> location = new LinkedHashMap<>();
+                    location.put("line", tag.getLocation().getLine());
+                    location.put("column", tag.getLocation().getColumn());
+                    json.put("location", location);
+                    return json;
+                }).collect(toList()));
+
+        }
+        return featureMap;
+    }
+
+    private Map<String, Object> createTestCase(TestCaseStarted event) {
+        Map<String, Object> testCaseMap = new HashMap<>();
+
+        testCaseMap.put("start_timestamp", getDateTimeFromTimeStamp(event.getInstant()));
+
+        TestCase testCase = event.getTestCase();
+
+        testCaseMap.put("name", testCase.getName());
+        testCaseMap.put("line", testCase.getLine());
+        testCaseMap.put("type", "scenario");
+        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testCase.getLine());
+        if (astNode != null) {
+            testCaseMap.put("id", TestSourcesModel.calculateId(astNode));
+            Scenario scenarioDefinition = TestSourcesModel.getScenarioDefinition(astNode);
+            testCaseMap.put("keyword", scenarioDefinition.getKeyword());
+            testCaseMap.put("description",
+                scenarioDefinition.getDescription() != null ? scenarioDefinition.getDescription() : "");
+        }
+        testCaseMap.put("steps", new ArrayList<Map<String, Object>>());
+        if (!testCase.getTags().isEmpty()) {
+            List<Map<String, Object>> tagList = new ArrayList<>();
+            for (String tag : testCase.getTags()) {
+                Map<String, Object> tagMap = new HashMap<>();
+                tagMap.put("name", tag);
+                tagList.add(tagMap);
+            }
+            testCaseMap.put("tags", tagList);
+        }
+        return testCaseMap;
+    }
+
+    private Map<String, Object> createBackground(TestCase testCase) {
+        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testCase.getLocation().getLine());
+        if (astNode != null) {
+            Background background = getBackgroundForTestCase(astNode).get();
+            Map<String, Object> testCaseMap = new HashMap<>();
+            testCaseMap.put("name", background.getName());
+            testCaseMap.put("line", background.getLocation().getLine());
+            testCaseMap.put("type", "background");
+            testCaseMap.put("keyword", background.getKeyword());
+            testCaseMap.put("description", background.getDescription() != null ? background.getDescription() : "");
+            testCaseMap.put("steps", new ArrayList<Map<String, Object>>());
+            return testCaseMap;
+        }
+        return null;
+    }
+
+    private boolean isFirstStepAfterBackground(PickleStepTestStep testStep) {
+        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testStep.getStepLine());
+        if (astNode == null) {
+            return false;
+        }
+        return currentElementMap != currentTestCaseMap && !TestSourcesModel.isBackgroundStep(astNode);
+    }
+
+    private Map<String, Object> createTestStep(PickleStepTestStep testStep) {
+        Map<String, Object> stepMap = new HashMap<>();
+        stepMap.put("name", testStep.getStepText());
+        stepMap.put("line", testStep.getStepLine());
+        TestSourcesModel.AstNode astNode = testSources.getAstNode(currentFeatureFile, testStep.getStepLine());
+        StepArgument argument = testStep.getStepArgument();
+        if (argument != null) {
+            if (argument instanceof DocStringArgument) {
+                DocStringArgument docStringArgument = (DocStringArgument) argument;
+                stepMap.put("doc_string", createDocStringMap(docStringArgument));
+            } else if (argument instanceof DataTableArgument) {
+                DataTableArgument dataTableArgument = (DataTableArgument) argument;
+                stepMap.put("rows", createDataTableList(dataTableArgument));
+            }
+        }
+        if (astNode != null) {
+            Step step = (Step) astNode.node;
+            stepMap.put("keyword", step.getKeyword());
+        }
+
+        return stepMap;
+    }
+
+    private Map<String, Object> createHookStep(HookTestStep hookTestStep) {
+        return new HashMap<>();
+    }
+
+    private void addHookStepToTestCaseMap(Map<String, Object> currentStepOrHookMap, HookType hookType) {
+        String hookName;
+        if (hookType == HookType.AFTER || hookType == HookType.AFTER_STEP)
+            hookName = after;
+        else
+            hookName = before;
+
+        Map<String, Object> mapToAddTo;
+        switch (hookType) {
+            case BEFORE:
+                mapToAddTo = currentTestCaseMap;
+                break;
+            case AFTER:
+                mapToAddTo = currentTestCaseMap;
+                break;
+            case BEFORE_STEP:
+                mapToAddTo = currentBeforeStepHookList;
+                break;
+            case AFTER_STEP:
+                mapToAddTo = currentStepsList.get(currentStepsList.size() - 1);
+                break;
+            default:
+                mapToAddTo = currentTestCaseMap;
+        }
+
+        if (!mapToAddTo.containsKey(hookName)) {
+            mapToAddTo.put(hookName, new ArrayList<Map<String, Object>>());
+        }
+        ((List<Map<String, Object>>) mapToAddTo.get(hookName)).add(currentStepOrHookMap);
+    }
+
+    private Map<String, Object> createMatchMap(TestStep step, Result result) {
+        Map<String, Object> matchMap = new HashMap<>();
+        if (step instanceof PickleStepTestStep) {
+            PickleStepTestStep testStep = (PickleStepTestStep) step;
+            if (!testStep.getDefinitionArgument().isEmpty()) {
+                List<Map<String, Object>> argumentList = new ArrayList<>();
+                for (Argument argument : testStep.getDefinitionArgument()) {
+                    Map<String, Object> argumentMap = new HashMap<>();
+                    if (argument.getValue() != null) {
+                        argumentMap.put("val", argument.getValue());
+                        argumentMap.put("offset", argument.getStart());
+                    }
+                    argumentList.add(argumentMap);
+                }
+                matchMap.put("arguments", argumentList);
+            }
+        }
+        if (!result.getStatus().is(Status.UNDEFINED)) {
+            matchMap.put("location", step.getCodeLocation());
+        }
+        return matchMap;
+    }
+
+    private Map<String, Object> createResultMap(Result result) {
+        Map<String, Object> resultMap = new HashMap<>();
+        resultMap.put("status", result.getStatus().name().toLowerCase(ROOT));
+        if (result.getError() != null) {
+            resultMap.put("error_message", printStackTrace(result.getError()));
+        }
+        if (!result.getDuration().isZero()) {
+            resultMap.put("duration", result.getDuration().toNanos());
+        }
+        return resultMap;
+    }
+
+    private void addOutputToHookMap(String text) {
+        if (!currentStepOrHookMap.containsKey("output")) {
+            currentStepOrHookMap.put("output", new ArrayList<String>());
+        }
+        ((List<String>) currentStepOrHookMap.get("output")).add(text);
+    }
+
+    private void addEmbeddingToHookMap(byte[] data, String mediaType, String name) {
+        if (!currentStepOrHookMap.containsKey("embeddings")) {
+            currentStepOrHookMap.put("embeddings", new ArrayList<Map<String, Object>>());
+        }
+        Map<String, Object> embedMap = createEmbeddingMap(data, mediaType, name);
+        ((List<Map<String, Object>>) currentStepOrHookMap.get("embeddings")).add(embedMap);
+    }
+
+    private Map<String, Object> createDummyFeatureForFailure(TestRunFinished event) {
+        Throwable exception = event.getResult().getError();
+
+        Map<String, Object> feature = new LinkedHashMap<>();
+        feature.put("line", 1);
+        {
+            Map<String, Object> scenario = new LinkedHashMap<>();
+            feature.put("elements", singletonList(scenario));
+
+            scenario.put("start_timestamp", getDateTimeFromTimeStamp(event.getInstant()));
+            scenario.put("line", 2);
+            scenario.put("name", "Failure while executing Cucumber");
+            scenario.put("description", "");
+            scenario.put("id", "failure;failure-while-executing-cucumber");
+            scenario.put("type", "scenario");
+            scenario.put("keyword", "Scenario");
+
+            Map<String, Object> when = new LinkedHashMap<>();
+            Map<String, Object> then = new LinkedHashMap<>();
+            scenario.put("steps", Arrays.asList(when, then));
+            {
+
+                {
+                    Map<String, Object> whenResult = new LinkedHashMap<>();
+                    when.put("result", whenResult);
+                    whenResult.put("duration", 0);
+                    whenResult.put("status", "passed");
+                }
+                when.put("line", 3);
+                when.put("name", "Cucumber failed while executing");
+                Map<String, Object> whenMatch = new LinkedHashMap<>();
+                when.put("match", whenMatch);
+                whenMatch.put("arguments", new ArrayList<>());
+                whenMatch.put("location", "io.cucumber.core.Failure.failure_while_executing_cucumber()");
+                when.put("keyword", "When ");
+
+                {
+                    Map<String, Object> thenResult = new LinkedHashMap<>();
+                    then.put("result", thenResult);
+                    thenResult.put("duration", 0);
+                    thenResult.put("error_message", printStackTrace(exception));
+                    thenResult.put("status", "failed");
+                }
+                then.put("line", 4);
+                then.put("name", "Cucumber will report this error:");
+                Map<String, Object> thenMatch = new LinkedHashMap<>();
+                then.put("match", thenMatch);
+                thenMatch.put("arguments", new ArrayList<>());
+                thenMatch.put("location", "io.cucumber.core.Failure.cucumber_reports_this_error()");
+                then.put("keyword", "Then ");
+            }
+
+            feature.put("name", "Test run failed");
+            feature.put("description", "There were errors during the execution");
+            feature.put("id", "failure");
+            feature.put("keyword", "Feature");
+            feature.put("uri", "classpath:io/cucumber/core/failure.feature");
+            feature.put("tags", new ArrayList<>());
+        }
+
+        return feature;
+    }
+
+    private String getDateTimeFromTimeStamp(Instant instant) {
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+                .withZone(ZoneOffset.UTC);
+        return formatter.format(instant);
+    }
+
+    private Map<String, Object> createDocStringMap(DocStringArgument docString) {
+        Map<String, Object> docStringMap = new HashMap<>();
+        docStringMap.put("value", docString.getContent());
+        docStringMap.put("line", docString.getLine());
+        docStringMap.put("content_type", docString.getMediaType());
+        return docStringMap;
+    }
+
+    private List<Map<String, List<String>>> createDataTableList(DataTableArgument argument) {
+        List<Map<String, List<String>>> rowList = new ArrayList<>();
+        for (List<String> row : argument.cells()) {
+            Map<String, List<String>> rowMap = new HashMap<>();
+            rowMap.put("cells", new ArrayList<>(row));
+            rowList.add(rowMap);
+        }
+        return rowList;
+    }
+
+    private Map<String, Object> createEmbeddingMap(byte[] data, String mediaType, String name) {
+        Map<String, Object> embedMap = new HashMap<>();
+        embedMap.put("mime_type", mediaType); // Should be media-type but not
+                                              // worth migrating for
+        embedMap.put("data", Base64.getEncoder().encodeToString(data));
+        if (name != null) {
+            embedMap.put("name", name);
+        }
+        return embedMap;
+    }
+
+}
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportData.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportData.java
new file mode 100644
index 0000000000..de71b21cae
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportData.java
@@ -0,0 +1,13 @@
+package io.cucumber.core.plugin;
+
+import io.cucumber.messages.types.Envelope;
+import io.cucumber.query.Query;
+
+class JsonReportData {
+
+    private final Query query = new Query();
+
+    void collect(Envelope envelope) {
+        query.update(envelope);
+    }
+}
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportWriter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportWriter.java
new file mode 100644
index 0000000000..290c9b4cb0
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/JsonReportWriter.java
@@ -0,0 +1,409 @@
+package io.cucumber.core.plugin;
+
+import io.cucumber.core.plugin.CucumberJvmJson.JvmArgument;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmDataTableRow;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmDocString;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmElement;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmElementType;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmFeature;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmLocation;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmLocationTag;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmMatch;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmResult;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmStatus;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmStep;
+import io.cucumber.core.plugin.CucumberJvmJson.JvmTag;
+import io.cucumber.messages.Convertor;
+import io.cucumber.messages.types.Attachment;
+import io.cucumber.messages.types.Background;
+import io.cucumber.messages.types.DataTable;
+import io.cucumber.messages.types.DocString;
+import io.cucumber.messages.types.Exception;
+import io.cucumber.messages.types.Feature;
+import io.cucumber.messages.types.GherkinDocument;
+import io.cucumber.messages.types.Group;
+import io.cucumber.messages.types.Hook;
+import io.cucumber.messages.types.HookType;
+import io.cucumber.messages.types.JavaMethod;
+import io.cucumber.messages.types.JavaStackTraceElement;
+import io.cucumber.messages.types.Pickle;
+import io.cucumber.messages.types.PickleStep;
+import io.cucumber.messages.types.Rule;
+import io.cucumber.messages.types.RuleChild;
+import io.cucumber.messages.types.Scenario;
+import io.cucumber.messages.types.SourceReference;
+import io.cucumber.messages.types.Step;
+import io.cucumber.messages.types.StepDefinition;
+import io.cucumber.messages.types.StepMatchArgumentsList;
+import io.cucumber.messages.types.TableCell;
+import io.cucumber.messages.types.Tag;
+import io.cucumber.messages.types.TestCaseStarted;
+import io.cucumber.messages.types.TestStep;
+import io.cucumber.messages.types.TestStepFinished;
+import io.cucumber.messages.types.TestStepResult;
+import io.cucumber.messages.types.TestStepResultStatus;
+import io.cucumber.messages.types.Timestamp;
+import io.cucumber.query.NamingStrategy;
+import io.cucumber.query.Query;
+
+import java.net.URI;
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collector;
+import java.util.stream.Stream;
+
+import static io.cucumber.core.plugin.TestSourcesModel.convertToId;
+import static io.cucumber.messages.types.HookType.AFTER;
+import static io.cucumber.messages.types.HookType.BEFORE;
+import static java.util.Locale.ROOT;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+
+class JsonReportWriter {
+    private final Query query;
+
+    JsonReportWriter(Query query) {
+        this.query = query;
+    }
+
+    List<Object> writeJsonReport() {
+        return query.findAllTestCaseStartedGroupedByFeature()
+                .entrySet()
+                .stream()
+                .map(this::createFeatureMap)
+                .collect(toList());
+    }
+
+    private JvmFeature createFeatureMap(Entry<Optional<Feature>, List<TestCaseStarted>> entry) {
+        GherkinDocument document = getGherkinDocument(entry);
+        Feature feature = entry.getKey().get();
+        return new JvmFeature(
+                TestSourcesModel.relativize(URI.create(document.getUri().get())).toString(), // TODO: Relativize, optional?, null?
+                convertToId(feature.getName()),
+                feature.getLocation().getLine(),
+                feature.getKeyword(),
+                feature.getName(),
+                feature.getDescription() != null ? feature.getDescription() : "", // TODO: Can this be null?
+                writeElementsReport(entry),
+                feature.getTags().stream()
+                        .map(JsonReportWriter::createLocationTag)
+                        .collect(toList())
+        );
+    }
+
+    private static JvmLocationTag createLocationTag(Tag tag) {
+        return new JvmLocationTag(
+                tag.getName(),
+                "Tag",
+                new JvmLocation(
+                        tag.getLocation().getLine(),
+                        tag.getLocation().getColumn().orElse(0L)
+                )
+        );
+    }
+
+    private GherkinDocument getGherkinDocument(Entry<Optional<Feature>, List<TestCaseStarted>> entry) {
+        return entry.getValue().stream()
+                .findFirst()
+                .flatMap(query::findGherkinDocumentBy)
+                .orElseThrow(() -> new IllegalArgumentException("No Gherkin document"));
+    }
+
+    private List<JvmElement> writeElementsReport(Entry<Optional<Feature>, List<TestCaseStarted>> entry) {
+        return entry.getValue().stream()
+                .map(this::createTestCaseAndBackGround)
+                .flatMap(Collection::stream)
+                .collect(toList());
+    }
+
+    private List<JvmElement> createTestCaseAndBackGround(TestCaseStarted testCaseStarted) {
+        // TODO: Clean up
+        Predicate<Entry<Optional<Background>, List<TestStepFinished>>> isBackGround = entry -> entry.getKey().isPresent();
+        Predicate<Entry<Optional<Background>, List<TestStepFinished>>> isTestCase = isBackGround.negate();
+        BinaryOperator<Entry<Optional<Background>, List<TestStepFinished>>> mergeSteps = (a, b) -> {
+            a.getValue().addAll(b.getValue());
+            return a;
+        };
+        Map<Optional<Background>, List<TestStepFinished>> stepsByBackground = query.findTestStepFinishedAndTestStepBy(testCaseStarted)
+                .stream()
+                .collect(groupByBackground(testCaseStarted));
+
+        // There can be multiple backgrounds, but historically the json format
+        // only ever had one. So we group all other backgrounds steps with the
+        // first.
+        Optional<JvmElement> background = stepsByBackground.entrySet().stream()
+                .filter(isBackGround)
+                .reduce(mergeSteps)
+                .flatMap(entry -> entry.getKey().map(bg -> createBackground(bg, entry.getValue())));
+
+        Optional<JvmElement> testCase = stepsByBackground.entrySet().stream()
+                .filter(isTestCase)
+                .reduce(mergeSteps)
+                .map(Entry::getValue)
+                .map(testStepFinished -> createTestCase(testCaseStarted, testStepFinished));
+
+        return Stream.of(background, testCase)
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .collect(toList());
+    }
+
+    private Collector<Entry<TestStepFinished, TestStep>, ?, Map<Optional<Background>, List<TestStepFinished>>> groupByBackground(TestCaseStarted testCaseStarted) {
+        List<Background> backgrounds = query.findFeatureBy(testCaseStarted)
+                .map(this::getBackgroundsBy)
+                .orElseGet(Collections::emptyList);
+
+        Function<Entry<TestStepFinished, TestStep>, Optional<Background>> grouping =
+                entry -> query.findPickleStepBy(entry.getValue())
+                        .flatMap(pickleStep -> findBackgroundBy(backgrounds, pickleStep));
+
+        Function<List<Entry<TestStepFinished, TestStep>>, List<TestStepFinished>> extractKey = entries -> entries.stream()
+                .map(Entry::getKey)
+                .collect(toList());
+
+        return groupingBy(grouping, LinkedHashMap::new, collectingAndThen(toList(), extractKey));
+    }
+
+    private static Optional<Background> findBackgroundBy(List<Background> backgrounds, PickleStep pickleStep) {
+        return backgrounds.stream()
+                .filter(background -> background.getSteps().stream()
+                        .map(Step::getId)
+                        .anyMatch(step -> pickleStep.getAstNodeIds().contains(step)))
+                .findFirst();
+    }
+
+    private List<Background> getBackgroundsBy(Feature feature) {
+        return feature.getChildren()
+                .stream()
+                .map(featureChild -> {
+                    List<Background> backgrounds = new ArrayList<>();
+                    featureChild.getBackground().ifPresent(backgrounds::add);
+                    featureChild.getRule()
+                            .map(Rule::getChildren)
+                            .map(Collection::stream)
+                            .orElseGet(Stream::empty)
+                            .map(RuleChild::getBackground)
+                            .filter(Optional::isPresent)
+                            .map(Optional::get)
+                            .forEach(backgrounds::add);
+                    return backgrounds;
+                })
+                .flatMap(Collection::stream)
+                .collect(toList());
+    }
+
+    private JvmElement createBackground(Background background, List<TestStepFinished> testStepsFinished) {
+        return new JvmElement(
+                null,
+                background.getLocation().getLine(),
+                null,
+                JvmElementType.background,
+                background.getKeyword(),
+                background.getName(),
+                background.getDescription() != null ? background.getDescription() : "",
+                createTestSteps(testStepsFinished),
+                null,
+                null,
+                null
+        );
+    }
+
+
+    private JvmElement createTestCase(TestCaseStarted event, List<TestStepFinished> testStepsFinished) {
+        Pickle pickle = query.findPickleBy(event).orElseThrow();
+        Scenario scenario = query.findScenarioBy(event).orElseThrow();
+        NamingStrategy idStrategy = NamingStrategy.strategy(NamingStrategy.Strategy.LONG).delimiter(";").namingVisitor(new IdNamingVisitor()).build();
+        List<CucumberJvmJson.JvmHook> beforeHooks = createHookSteps(testStepsFinished, include(BEFORE));
+        List<CucumberJvmJson.JvmHook> afterHooks = createHookSteps(testStepsFinished, include(AFTER));
+        return new JvmElement(
+                getDateTimeFromTimeStamp(event.getTimestamp()),
+                query.findLocationOf(pickle).orElseThrow().getLine(),
+                query.findNameOf(pickle, idStrategy),
+                JvmElementType.scenario,
+                scenario.getKeyword(),
+                pickle.getName(),
+                scenario.getDescription() != null ? scenario.getDescription() : "",
+                createTestSteps(testStepsFinished),
+                beforeHooks.isEmpty() ? null : beforeHooks,
+                afterHooks.isEmpty() ? null : afterHooks,
+                pickle.getTags().isEmpty() ? null : createTags(pickle)
+        );
+    }
+
+    private List<CucumberJvmJson.JvmHook> createHookSteps(List<TestStepFinished> testStepsFinished, Predicate<Hook> predicate) {
+        return testStepsFinished.stream()
+                .map(testStepFinished -> query.findTestStepBy(testStepFinished)
+                        .flatMap(testStep -> query.findHookBy(testStep)
+                                .filter(predicate)
+                                .map(hook -> new CucumberJvmJson.JvmHook(
+                                        createMatchMap(testStep, testStepFinished.getTestStepResult()),
+                                        createResultMap(testStepFinished.getTestStepResult()),
+                                        createEmbeddings(query.findAllAttachmentsBy(testStep))
+                                ))))
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .collect(toList());
+    }
+
+    private List<CucumberJvmJson.JvmEmbedding> createEmbeddings(List<Attachment> attachments) {
+        if (attachments.isEmpty()) {
+            return null;
+        }
+        return attachments.stream()
+                .map(attachment -> new CucumberJvmJson.JvmEmbedding(
+                        attachment.getMediaType(),
+                        attachment.getBody(),
+                        attachment.getFileName().orElse(null)
+                )).collect(toList());
+    }
+
+    private List<JvmStep> createTestSteps(List<TestStepFinished> testStepsFinished) {
+        return testStepsFinished.stream()
+                .map(this::createTestStep)
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .collect(toList());
+    }
+
+    private static List<JvmTag> createTags(Pickle pickle) {
+        return pickle.getTags().stream().map(pickleTag -> new JvmTag(pickleTag.getName())).collect(toList());
+    }
+
+    private Optional<JvmStep> createTestStep(TestStepFinished testStepFinished) {
+        return query.findTestStepBy(testStepFinished)
+                .flatMap(testStep -> query.findPickleStepBy(testStep)
+                        .flatMap(pickleStep -> query.findStepBy(pickleStep)
+                                .map(step -> new JvmStep(
+                                        step.getKeyword(),
+                                        step.getLocation().getLine(),
+                                        createMatchMap(testStep, testStepFinished.getTestStepResult()),
+                                        pickleStep.getText(),
+                                        createResultMap(testStepFinished.getTestStepResult()),
+                                        step.getDocString().map(this::createDocStringMap).orElse(null),
+                                        step.getDataTable().map(this::createDataTableList).orElse(null)
+                                ))
+                        )
+                );
+    }
+
+    private static Predicate<Hook> include(HookType... hookTypes) {
+        List<HookType> keep = Arrays.asList(hookTypes);
+        return hook -> hook.getType().map(keep::contains).orElse(false);
+    }
+
+    private static Predicate<Hook> exclude(HookType... hookTypes) {
+        return include(hookTypes).negate();
+    }
+
+    private JvmMatch createMatchMap(TestStep step, TestStepResult result) {
+        Optional<SourceReference> source = query.findStepDefinitionBy(step)
+                .stream()
+                .findFirst()
+                .map(StepDefinition::getSourceReference);
+
+        Optional<String> location = source.flatMap(sourceReference -> {
+            Optional<String> fromUri = sourceReference.getUri()
+                    .map(uri -> renderLocationString(sourceReference, uri));
+
+            Optional<String> fromJavaMethod = sourceReference.getJavaMethod()
+                    .map(JsonReportWriter::renderLocationString);
+
+            Optional<String> fromStackTrace = sourceReference.getJavaStackTraceElement()
+                    .map(javaStackTraceElement -> renderLocationString(sourceReference, javaStackTraceElement));
+
+            return Stream.of(fromStackTrace, fromJavaMethod, fromUri).filter(Optional::isPresent).map(Optional::get).findFirst();
+        });
+
+        Optional<List<JvmArgument>> argumentList = step.getStepMatchArgumentsLists()
+                .map(argumentsLists -> argumentsLists.stream()
+                        .map(StepMatchArgumentsList::getStepMatchArguments)
+                        .flatMap(Collection::stream)
+                        .map(argument -> {
+                            Group group = argument.getGroup();
+                            return new JvmArgument(
+                                    // TODO: Nullable
+                                    group.getValue().get(),
+                                    group.getStart().get()
+                            );
+                        }).collect(toList()))
+                .filter(maps -> !maps.isEmpty());
+
+        return new JvmMatch(
+                result.getStatus() != TestStepResultStatus.UNDEFINED ? location.orElse(null) : null,
+                argumentList.orElse(null)
+        );
+    }
+
+    private static String renderLocationString(SourceReference sourceReference, String uri) {
+        String locationLine = sourceReference.getLocation().map(location -> ":" + location.getLine()).orElse("");
+        return uri + locationLine;
+    }
+
+    private static String renderLocationString(SourceReference sourceReference, JavaStackTraceElement javaStackTraceElement) {
+        String locationLine = sourceReference.getLocation().map(location -> ":" + location.getLine()).orElse("");
+        String argumentList = String.join(",", javaStackTraceElement.getFileName());
+        return String.format(
+                "%s#%s(%s%s)",
+                javaStackTraceElement.getClassName(),
+                javaStackTraceElement.getMethodName(),
+                argumentList,
+                locationLine
+        );
+    }
+
+    private static String renderLocationString(JavaMethod javaMethod) {
+        return String.format(
+                "%s#%s(%s)",
+                javaMethod.getClassName(),
+                javaMethod.getMethodName(),
+                String.join(",", javaMethod.getMethodParameterTypes())
+        );
+    }
+
+    private JvmResult createResultMap(TestStepResult result) {
+        Duration duration = Convertor.toDuration(result.getDuration());
+        return new JvmResult(
+                duration.isZero() ? null : duration.toNanos(),
+                JvmStatus.valueOf(result.getStatus().name().toLowerCase(ROOT)),
+                result.getException().flatMap(Exception::getStackTrace).orElse(null)
+        );
+    }
+
+    private JvmDocString createDocStringMap(DocString docString) {
+        return new JvmDocString(
+                docString.getLocation().getLine(),
+                docString.getContent(),
+                docString.getMediaType().orElse(null)
+        );
+    }
+
+    private List<JvmDataTableRow> createDataTableList(DataTable argument) {
+        return argument.getRows().stream()
+                .map(row -> new JvmDataTableRow(row.getCells().stream()
+                        .map(TableCell::getValue)
+                        .collect(toList())))
+                .collect(toList());
+    }
+
+    private String getDateTimeFromTimeStamp(Timestamp instant) {
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+                .withZone(ZoneOffset.UTC);
+        return formatter.format(Convertor.toInstant(instant));
+    }
+
+
+}
diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/MessagesToJsonWriter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/MessagesToJsonWriter.java
new file mode 100644
index 0000000000..3205a1700f
--- /dev/null
+++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/MessagesToJsonWriter.java
@@ -0,0 +1,81 @@
+package io.cucumber.core.plugin;
+
+
+import io.cucumber.messages.types.Envelope;
+import io.cucumber.query.Query;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Writes the message output of a test run as single json report.
+ * <p>
+ * Note: Messages are first collected and only written once the stream is closed.
+ */
+public class MessagesToJsonWriter implements AutoCloseable {
+
+    private final OutputStreamWriter out;
+    private final Query query = new Query();
+    private final Serializer serializer;
+    private boolean streamClosed = false;
+
+    public MessagesToJsonWriter(OutputStream out, Serializer serializer) {
+        this.out = new OutputStreamWriter(
+                requireNonNull(out),
+                StandardCharsets.UTF_8
+        );
+        this.serializer = serializer;
+    }
+
+
+    /**
+     * Writes a cucumber message to the xml output.
+     *
+     * @param envelope the message
+     * @throws IOException if an IO error occurs
+     */
+    public void write(Envelope envelope) throws IOException {
+        if (streamClosed) {
+            throw new IOException("Stream closed");
+        }
+        query.update(envelope);
+    }
+
+    /**
+     * Closes the stream, flushing it first. Once closed further write()
+     * invocations will cause an IOException to be thrown. Closing a closed
+     * stream has no effect.
+     *
+     * @throws IOException if an IO error occurs
+     */
+    @Override
+    public void close() throws IOException {
+        if (streamClosed) {
+            return;
+        }
+        try {
+            List<Object> report = new JsonReportWriter(query).writeJsonReport();
+            serializer.writeValue(out, report);
+        } finally {
+            try {
+                out.close();
+            } finally {
+                streamClosed = true;
+            }
+        }
+    }
+
+    @FunctionalInterface
+    public interface Serializer {
+
+        void writeValue(Writer writer, Object value) throws IOException;
+
+    }
+}
+
diff --git a/cucumber-core/src/main/java/io/cucumber/core/runner/CachingGlue.java b/cucumber-core/src/main/java/io/cucumber/core/runner/CachingGlue.java
index 5962758d5b..e3a818003e 100644
--- a/cucumber-core/src/main/java/io/cucumber/core/runner/CachingGlue.java
+++ b/cucumber-core/src/main/java/io/cucumber/core/runner/CachingGlue.java
@@ -28,6 +28,7 @@
 import io.cucumber.datatable.TableEntryByTypeTransformer;
 import io.cucumber.messages.types.Envelope;
 import io.cucumber.messages.types.Hook;
+import io.cucumber.messages.types.HookType;
 import io.cucumber.messages.types.JavaMethod;
 import io.cucumber.messages.types.JavaStackTraceElement;
 import io.cucumber.messages.types.Location;
@@ -47,6 +48,11 @@
 import java.util.Map;
 import java.util.TreeMap;
 
+import static io.cucumber.messages.types.HookType.AFTER;
+import static io.cucumber.messages.types.HookType.AFTER_STEP;
+import static io.cucumber.messages.types.HookType.BEFORE;
+import static io.cucumber.messages.types.HookType.BEFORE_STEP;
+
 final class CachingGlue implements Glue {
 
     private static final Comparator<CoreHookDefinition> HOOK_ORDER_ASCENDING = Comparator
@@ -266,8 +272,8 @@ void prepareGlue(StepTypeRegistry stepTypeRegistry) throws DuplicateStepDefiniti
 
         // TODO: Redefine hooks for each scenario, similar to how we're doing
         // for CoreStepDefinition
-        beforeHooks.forEach(this::emitHook);
-        beforeStepHooks.forEach(this::emitHook);
+        beforeHooks.forEach(hook -> emitHook(hook, BEFORE));
+        beforeStepHooks.forEach(hook -> emitHook(hook, BEFORE_STEP));
 
         stepDefinitions.forEach(stepDefinition -> {
             StepExpression expression = stepExpressionFactory.createExpression(stepDefinition);
@@ -281,8 +287,8 @@ void prepareGlue(StepTypeRegistry stepTypeRegistry) throws DuplicateStepDefiniti
             emitStepDefined(coreStepDefinition);
         });
 
-        afterStepHooks.forEach(this::emitHook);
-        afterHooks.forEach(this::emitHook);
+        afterStepHooks.forEach(hook -> emitHook(hook, AFTER_STEP));
+        afterHooks.forEach(hook -> emitHook(hook, AFTER));
     }
 
     private void emitParameterTypeDefined(ParameterTypeDefinition parameterTypeDefinition) {
@@ -299,14 +305,14 @@ private void emitParameterTypeDefined(ParameterTypeDefinition parameterTypeDefin
         bus.send(Envelope.of(messagesParameterType));
     }
 
-    private void emitHook(CoreHookDefinition coreHook) {
+    private void emitHook(CoreHookDefinition coreHook, HookType type) {
         Hook messagesHook = new Hook(
             coreHook.getId().toString(),
             null,
             coreHook.getDefinitionLocation()
                     .map(this::createSourceReference)
                     .orElseGet(this::emptySourceReference),
-            coreHook.getTagExpression());
+            coreHook.getTagExpression(), type);
         bus.send(Envelope.of(messagesHook));
     }
 
diff --git a/cucumber-core/src/test/java/io/cucumber/core/backend/StubHookDefinition.java b/cucumber-core/src/test/java/io/cucumber/core/backend/StubHookDefinition.java
index 2ae7a4a5ed..3431066e6a 100644
--- a/cucumber-core/src/test/java/io/cucumber/core/backend/StubHookDefinition.java
+++ b/cucumber-core/src/test/java/io/cucumber/core/backend/StubHookDefinition.java
@@ -1,16 +1,23 @@
 package io.cucumber.core.backend;
 
+import java.lang.reflect.Method;
+import java.util.Optional;
 import java.util.function.Consumer;
 
 public class StubHookDefinition implements HookDefinition {
 
     private static final String STUBBED_LOCATION_WITH_DETAILS = "{stubbed location with details}";
-    private final String location;
+    private final StubLocation location;
     private final RuntimeException exception;
     private final Consumer<TestCaseState> action;
 
+    public StubHookDefinition(Method location, RuntimeException exception, Consumer<TestCaseState> action) {
+        this.location = new StubLocation(location);
+        this.exception = exception;
+        this.action = action;
+    }
     public StubHookDefinition(String location, RuntimeException exception, Consumer<TestCaseState> action) {
-        this.location = location;
+        this.location = new StubLocation(location);
         this.exception = exception;
         this.action = action;
     }
@@ -19,6 +26,10 @@ public StubHookDefinition(String location, Consumer<TestCaseState> action) {
         this(location, null, action);
     }
 
+    public StubHookDefinition(Method location, Consumer<TestCaseState> action) {
+        this(location, null, action);
+    }
+
     public StubHookDefinition() {
         this(STUBBED_LOCATION_WITH_DETAILS, null, null);
     }
@@ -35,6 +46,10 @@ public StubHookDefinition(String location) {
         this(location, null, null);
     }
 
+    public StubHookDefinition(Method location) {
+        this(location, null, null);
+    }
+
     @Override
     public void execute(TestCaseState state) {
         if (action != null) {
@@ -62,7 +77,11 @@ public boolean isDefinedAt(StackTraceElement stackTraceElement) {
 
     @Override
     public String getLocation() {
-        return location;
+        return location.getLocation();
     }
 
+    @Override
+    public Optional<SourceReference> getSourceReference() {
+        return location.getSourceReference();
+    }
 }
diff --git a/cucumber-core/src/test/java/io/cucumber/core/backend/StubLocation.java b/cucumber-core/src/test/java/io/cucumber/core/backend/StubLocation.java
index 63b03f418b..dc63ac69ee 100644
--- a/cucumber-core/src/test/java/io/cucumber/core/backend/StubLocation.java
+++ b/cucumber-core/src/test/java/io/cucumber/core/backend/StubLocation.java
@@ -1,11 +1,21 @@
 package io.cucumber.core.backend;
 
+import java.lang.reflect.Method;
+import java.util.Optional;
+
 public class StubLocation implements Located {
 
     private final String location;
+    private final SourceReference sourceReference;
 
     public StubLocation(String location) {
         this.location = location;
+        this.sourceReference = null;
+    }
+
+    public StubLocation(Method method) {
+        this.location = null;
+        this.sourceReference = SourceReference.fromMethod(method);
     }
 
     @Override
@@ -13,6 +23,11 @@ public boolean isDefinedAt(StackTraceElement stackTraceElement) {
         return false;
     }
 
+    @Override
+    public Optional<SourceReference> getSourceReference() {
+        return Optional.ofNullable(sourceReference);
+    }
+
     @Override
     public String getLocation() {
         return location;
diff --git a/cucumber-core/src/test/java/io/cucumber/core/backend/StubStepDefinition.java b/cucumber-core/src/test/java/io/cucumber/core/backend/StubStepDefinition.java
index c09c631296..48084e7a9c 100644
--- a/cucumber-core/src/test/java/io/cucumber/core/backend/StubStepDefinition.java
+++ b/cucumber-core/src/test/java/io/cucumber/core/backend/StubStepDefinition.java
@@ -1,8 +1,10 @@
 package io.cucumber.core.backend;
 
 import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.lang.reflect.Type;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -20,6 +22,10 @@ public StubStepDefinition(String pattern, String location, Type... types) {
         this(pattern, location, null, types);
     }
 
+    public StubStepDefinition(String pattern, Method location, Type... types) {
+        this(pattern, location, null, types);
+    }
+
     public StubStepDefinition(String pattern, Type... types) {
         this(pattern, STUBBED_LOCATION_WITH_DETAILS, null, types);
     }
@@ -35,6 +41,14 @@ public StubStepDefinition(String pattern, String location, Throwable exception,
         this.exception = exception;
     }
 
+
+    public StubStepDefinition(String pattern, Method location, Throwable exception, Type... types) {
+        this.parameterInfos = Stream.of(types).map(StubParameterInfo::new).collect(Collectors.toList());
+        this.expression = pattern;
+        this.location = new StubLocation(location);
+        this.exception = exception;
+    }
+
     @Override
     public boolean isDefinedAt(StackTraceElement stackTraceElement) {
         return false;
@@ -45,6 +59,11 @@ public String getLocation() {
         return location.getLocation();
     }
 
+    @Override
+    public Optional<SourceReference> getSourceReference() {
+        return location.getSourceReference();
+    }
+
     @Override
     public void execute(Object[] args) {
         if (exception != null) {
diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/HtmlFormatterTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/HtmlFormatterTest.java
index 1e5132b263..562e0361cd 100644
--- a/cucumber-core/src/test/java/io/cucumber/core/plugin/HtmlFormatterTest.java
+++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/HtmlFormatterTest.java
@@ -64,7 +64,7 @@ void ignores_step_definitions() throws Throwable {
         Hook hook = new Hook("",
             null,
             SourceReference.of("https://example.com"),
-            null);
+            null, null);
         bus.send(Envelope.of(hook));
 
         // public ParameterType(String name, List<String> regularExpressions,
diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/JsonFormatterTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/JsonFormatterTest.java
index c96d7aca1a..f7842c05e1 100644
--- a/cucumber-core/src/test/java/io/cucumber/core/plugin/JsonFormatterTest.java
+++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/JsonFormatterTest.java
@@ -2,6 +2,7 @@
 
 import io.cucumber.core.backend.StubHookDefinition;
 import io.cucumber.core.backend.StubStepDefinition;
+import io.cucumber.core.eventbus.IncrementingUuidGenerator;
 import io.cucumber.core.feature.TestFeatureParser;
 import io.cucumber.core.gherkin.Feature;
 import io.cucumber.core.options.RuntimeOptionsBuilder;
@@ -18,6 +19,7 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
+import java.lang.reflect.Method;
 import java.util.Scanner;
 import java.util.UUID;
 
@@ -33,6 +35,23 @@
 
 class JsonFormatterTest {
 
+    final Method monkeyArrives = getMethod("monkey_arrives");
+    final Method thereAreBananas = getMethod("there_are_bananas");
+    final Method thereAreOranges = getMethod("there_are_oranges");
+    final Method beforeHook1 = getMethod("before_hook_1");
+    final Method afterHook1 = getMethod("after_hook_1");
+
+    final Method monkeyEatsBananas = getMethod("monkey_eats_bananas");
+    final Method monkeyEatsMoreBananas = getMethod("monkey_eats_more_bananas");
+
+    private static Method getMethod(String name)  {
+        try {
+            return StepDefs.class.getMethod(name);
+        } catch (NoSuchMethodException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     @Test
     void featureWithOutlineTest() throws JSONException {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -168,9 +187,9 @@ void should_format_scenario_with_a_passed_step() throws JSONException {
         Runtime.builder()
                 .withFeatureSupplier(new StubFeatureSupplier(feature))
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
-                .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
+                .withEventBus(new TimeServiceEventBus(timeService, new IncrementingUuidGenerator()))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas)))
                 .build()
                 .run();
 
@@ -198,7 +217,7 @@ void should_format_scenario_with_a_passed_step() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -229,7 +248,7 @@ void should_format_scenario_with_a_failed_step() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()",
+                    new StubStepDefinition("there are bananas", thereAreBananas,
                         new StubException())))
                 .build()
                 .run();
@@ -258,7 +277,7 @@ void should_format_scenario_with_a_failed_step() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"failed\",\n" +
@@ -291,7 +310,7 @@ void should_format_scenario_with_a_rule() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas)))
                 .build()
                 .run();
 
@@ -305,7 +324,7 @@ void should_format_scenario_with_a_rule() throws JSONException {
                 "        \"line\": 4,\n" +
                 "        \"name\": \"Monkey eats bananas\",\n" +
                 "        \"description\": \"\",\n" +
-                "        \"id\": \";monkey-eats-bananas\",\n" +
+                "        \"id\": \"banana-party;this-is-all-monkey-business;monkey-eats-bananas\",\n" +
                 "        \"type\": \"scenario\",\n" +
                 "        \"keyword\": \"Scenario\",\n" +
                 "        \"steps\": [\n" +
@@ -317,7 +336,7 @@ void should_format_scenario_with_a_rule() throws JSONException {
                 "            \"line\": 5,\n" +
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"keyword\": \"Given \"\n" +
                 "          }\n" +
@@ -358,7 +377,7 @@ void should_format_scenario_with_a_rule_and_background() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas)))
                 .build()
                 .run();
 
@@ -382,7 +401,7 @@ void should_format_scenario_with_a_rule_and_background() throws JSONException {
                 "            \"line\": 4,\n" +
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"keyword\": \"Given \"\n" +
                 "          },\n" +
@@ -394,7 +413,7 @@ void should_format_scenario_with_a_rule_and_background() throws JSONException {
                 "            \"line\": 9,\n" +
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"keyword\": \"Given \"\n" +
                 "          }\n" +
@@ -417,7 +436,7 @@ void should_format_scenario_with_a_rule_and_background() throws JSONException {
                 "            \"line\": 12,\n" +
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"keyword\": \"Given \"\n" +
                 "          }\n" +
@@ -453,7 +472,7 @@ void should_format_scenario_outline_with_one_example() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas)))
                 .build()
                 .run();
 
@@ -481,7 +500,7 @@ void should_format_scenario_outline_with_one_example() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -518,9 +537,9 @@ void should_format_feature_with_background() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()"),
-                    new StubStepDefinition("the monkey eats bananas", "StepDefs.monkey_eats_bananas()"),
-                    new StubStepDefinition("the monkey eats more bananas", "StepDefs.monkey_eats_more_bananas()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas),
+                    new StubStepDefinition("the monkey eats bananas", monkeyEatsBananas),
+                    new StubStepDefinition("the monkey eats more bananas", monkeyEatsMoreBananas)))
                 .build()
                 .run();
 
@@ -546,7 +565,7 @@ void should_format_feature_with_background() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -569,7 +588,7 @@ void should_format_feature_with_background() throws JSONException {
                 "            \"name\": \"the monkey eats bananas\",\n" +
                 "            \"line\": 7,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.monkey_eats_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#monkey_eats_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -590,7 +609,7 @@ void should_format_feature_with_background() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -613,7 +632,7 @@ void should_format_feature_with_background() throws JSONException {
                 "            \"name\": \"the monkey eats more bananas\",\n" +
                 "            \"line\": 10,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.monkey_eats_more_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#monkey_eats_more_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -645,7 +664,7 @@ void should_format_feature_and_scenario_with_tags() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("the monkey eats more bananas", "StepDefs.monkey_eats_more_bananas()")))
+                    new StubStepDefinition("the monkey eats more bananas", monkeyEatsMoreBananas)))
                 .build()
                 .run();
 
@@ -671,7 +690,7 @@ void should_format_feature_and_scenario_with_tags() throws JSONException {
                 "            \"line\": 5,\n" +
                 "            \"name\": \"the monkey eats more bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.monkey_eats_more_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#monkey_eats_more_bananas()\"\n" +
                 "            },\n" +
                 "            \"keyword\": \"Then \"\n" +
                 "          }\n" +
@@ -732,9 +751,9 @@ void should_format_scenario_with_hooks() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    singletonList(new StubHookDefinition("Hooks.before_hook_1()")),
-                    singletonList(new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")),
-                    singletonList(new StubHookDefinition("Hooks.after_hook_1()"))))
+                    singletonList(new StubHookDefinition(beforeHook1)),
+                    singletonList(new StubStepDefinition("there are bananas", thereAreBananas)),
+                    singletonList(new StubHookDefinition(afterHook1))))
                 .build()
                 .run();
 
@@ -773,7 +792,7 @@ void should_format_scenario_with_hooks() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -819,8 +838,8 @@ void should_add_step_hooks_to_step() throws JSONException {
                     emptyList(),
                     singletonList(new StubHookDefinition("Hooks.beforestep_hooks_1()")),
                     asList(
-                        new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()"),
-                        new StubStepDefinition("monkey arrives", "StepDefs.monkey_arrives()")),
+                        new StubStepDefinition("there are bananas", thereAreBananas),
+                        new StubStepDefinition("monkey arrives", monkeyArrives)),
                     asList(
                         new StubHookDefinition("Hooks.afterstep_hooks_1()"),
                         new StubHookDefinition("Hooks.afterstep_hooks_2()")),
@@ -861,7 +880,7 @@ void should_add_step_hooks_to_step() throws JSONException {
                 "            \"line\": 4,\n" +
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"after\": [\n" +
                 "              {\n" +
@@ -904,7 +923,7 @@ void should_add_step_hooks_to_step() throws JSONException {
                 "            \"line\": 5,\n" +
                 "            \"name\": \"monkey arrives\",\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.monkey_arrives()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#monkey_arrives()\"\n" +
                 "            },\n" +
                 "            \"after\": [\n" +
                 "              {\n" +
@@ -957,9 +976,9 @@ void should_handle_write_from_a_hook() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    singletonList(new StubHookDefinition("Hooks.before_hook_1()",
+                    singletonList(new StubHookDefinition(beforeHook1,
                         testCaseState -> testCaseState.log("printed from hook"))),
-                    singletonList(new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")),
+                    singletonList(new StubStepDefinition("there are bananas", thereAreBananas)),
                     emptyList()))
                 .build()
                 .run();
@@ -1002,7 +1021,7 @@ void should_handle_write_from_a_hook() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1036,7 +1055,7 @@ void should_handle_embed_from_a_hook() throws JSONException {
                     singletonList(new StubHookDefinition("Hooks.before_hook_1()",
                         testCaseState -> testCaseState
                                 .attach(new byte[] { 1, 2, 3 }, "mime-type;base64", null))),
-                    singletonList(new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")),
+                    singletonList(new StubStepDefinition("there are bananas", thereAreBananas)),
                     emptyList()))
                 .build()
                 .run();
@@ -1082,7 +1101,7 @@ void should_handle_embed_from_a_hook() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1116,7 +1135,7 @@ void should_handle_embed_with_name_from_a_hook() throws JSONException {
                     singletonList(new StubHookDefinition("Hooks.before_hook_1()",
                         testCaseState -> testCaseState.attach(new byte[] { 1, 2, 3 }, "mime-type;base64",
                             "someEmbedding"))),
-                    singletonList(new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()")),
+                    singletonList(new StubStepDefinition("there are bananas", thereAreBananas)),
                     emptyList()))
                 .build()
                 .run();
@@ -1163,7 +1182,7 @@ void should_handle_embed_with_name_from_a_hook() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1197,7 +1216,7 @@ void should_format_scenario_with_a_step_with_a_doc_string() throws JSONException
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()", String.class)))
+                    new StubStepDefinition("there are bananas", thereAreBananas, String.class)))
                 .build()
                 .run();
 
@@ -1229,7 +1248,7 @@ void should_format_scenario_with_a_step_with_a_doc_string() throws JSONException
                 "              \"line\": 5\n" +
                 "            },\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1263,7 +1282,7 @@ void should_format_scenario_with_a_step_with_a_doc_string_and_content_type() thr
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()", DocString.class)))
+                    new StubStepDefinition("there are bananas", thereAreBananas, DocString.class)))
                 .build()
                 .run();
 
@@ -1296,7 +1315,7 @@ void should_format_scenario_with_a_step_with_a_doc_string_and_content_type() thr
                 "              \"line\": 5\n" +
                 "            },\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1329,7 +1348,7 @@ void should_format_scenario_with_a_step_with_a_data_table() throws JSONException
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()", DataTable.class)))
+                    new StubStepDefinition("there are bananas", thereAreBananas, DataTable.class)))
                 .build()
                 .run();
 
@@ -1371,7 +1390,7 @@ void should_format_scenario_with_a_step_with_a_data_table() throws JSONException
                 "              }\n" +
                 "            ],\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1407,8 +1426,8 @@ void should_handle_several_features() throws JSONException {
                 .withAdditionalPlugins(timeService, new JsonFormatter(out))
                 .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID))
                 .withBackendSupplier(new StubBackendSupplier(
-                    new StubStepDefinition("there are bananas", "StepDefs.there_are_bananas()"),
-                    new StubStepDefinition("there are oranges", "StepDefs.there_are_oranges()")))
+                    new StubStepDefinition("there are bananas", thereAreBananas),
+                    new StubStepDefinition("there are oranges", thereAreOranges)))
                 .build()
                 .run();
 
@@ -1436,7 +1455,7 @@ void should_handle_several_features() throws JSONException {
                 "            \"name\": \"there are bananas\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_bananas()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_bananas()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1470,7 +1489,7 @@ void should_handle_several_features() throws JSONException {
                 "            \"name\": \"there are oranges\",\n" +
                 "            \"line\": 4,\n" +
                 "            \"match\": {\n" +
-                "              \"location\": \"StepDefs.there_are_oranges()\"\n" +
+                "              \"location\": \"io.cucumber.core.plugin.JsonFormatterTest$StepDefs#there_are_oranges()\"\n" +
                 "            },\n" +
                 "            \"result\": {\n" +
                 "              \"status\": \"passed\",\n" +
@@ -1486,4 +1505,27 @@ void should_handle_several_features() throws JSONException {
         assertJsonEquals(expected, out);
     }
 
+    static class StepDefs {
+        public void before_hook_1(){
+
+        }
+        public void after_hook_1(){
+
+        }
+        public void there_are_bananas(){
+
+        }
+        public void there_are_oranges(){
+
+        }
+        public void monkey_eats_bananas(){
+
+        }
+        public void monkey_eats_more_bananas(){
+
+        }
+        public void monkey_arrives(){
+
+        }
+    }
 }