diff --git a/tools/junit/src/main/java/org/teavm/junit/BaseWebAssemblyPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/BaseWebAssemblyPlatformSupport.java
new file mode 100644
index 000000000..5ebdc4367
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/BaseWebAssemblyPlatformSupport.java
@@ -0,0 +1,68 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static org.teavm.junit.PropertyNames.SOURCE_DIRS;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.StringTokenizer;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.teavm.backend.wasm.WasmRuntimeType;
+import org.teavm.backend.wasm.WasmTarget;
+import org.teavm.backend.wasm.generate.DirectorySourceFileResolver;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.ReferenceCache;
+import org.teavm.vm.TeaVM;
+
+abstract class BaseWebAssemblyPlatformSupport extends TestPlatformSupport<WasmTarget> {
+    public BaseWebAssemblyPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        super(classSource, referenceCache);
+    }
+
+    @Override
+    String getExtension() {
+        return ".wasm";
+    }
+
+    protected abstract WasmRuntimeType getRuntimeType();
+
+    @Override
+    CompileResult compile(Consumer<TeaVM> additionalProcessing, String baseName,
+            TeaVMTestConfiguration<WasmTarget> configuration, File path) {
+        Supplier<WasmTarget> targetSupplier = () -> {
+            WasmTarget target = new WasmTarget();
+            target.setRuntimeType(getRuntimeType());
+            var sourceDirs = System.getProperty(SOURCE_DIRS);
+            if (sourceDirs != null) {
+                var dirs = new ArrayList<File>();
+                for (var tokenizer = new StringTokenizer(sourceDirs, Character.toString(File.pathSeparatorChar));
+                     tokenizer.hasMoreTokens();) {
+                    var dir = new File(tokenizer.nextToken());
+                    if (dir.isDirectory()) {
+                        dirs.add(dir);
+                    }
+                }
+                if (!dirs.isEmpty()) {
+                    target.setSourceFileResolver(new DirectorySourceFileResolver(dirs));
+                }
+            }
+            return target;
+        };
+        return compile(configuration, targetSupplier, TestNativeEntryPoint.class.getName(), path,
+                ".wasm", null, false, additionalProcessing, baseName);
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java b/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java
index f4ed56c6e..6b850ed97 100644
--- a/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java
+++ b/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java
@@ -15,11 +15,13 @@
  */
 package org.teavm.junit;
 
+import static org.teavm.junit.PropertyNames.JS_DECODE_STACK;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
@@ -28,6 +30,8 @@ import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.StringReader;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -38,6 +42,7 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
 import java.util.function.Function;
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletException;
@@ -55,7 +60,7 @@ import org.eclipse.jetty.websocket.api.WebSocketPolicy;
 import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
 
 class BrowserRunStrategy implements TestRunStrategy {
-    private boolean decodeStack = Boolean.parseBoolean(System.getProperty(TeaVMTestRunner.JS_DECODE_STACK, "true"));
+    private boolean decodeStack = Boolean.parseBoolean(System.getProperty(JS_DECODE_STACK, "true"));
     private final File baseDir;
     private final String type;
     private final Function<String, Process> browserRunner;
@@ -409,4 +414,122 @@ class BrowserRunStrategy implements TestRunStrategy {
             }
         }
     }
+
+    static Process customBrowser(String url) {
+        System.out.println("Open link to run tests: " + url + "?logging=true");
+        return null;
+    }
+
+    static Process chromeBrowser(String url) {
+        return browserTemplate("chrome", url, (profile, params) -> {
+            addChromeCommand(params);
+            params.addAll(Arrays.asList(
+                    "--headless",
+                    "--disable-gpu",
+                    "--remote-debugging-port=9222",
+                    "--no-first-run",
+                    "--user-data-dir=" + profile
+            ));
+        });
+    }
+
+    static Process firefoxBrowser(String url) {
+        return browserTemplate("firefox", url, (profile, params) -> {
+            addFirefoxCommand(params);
+            params.addAll(Arrays.asList(
+                    "--headless",
+                    "--profile",
+                    profile
+            ));
+        });
+    }
+
+    private static void addChromeCommand(List<String> params) {
+        if (isMacos()) {
+            params.add("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
+        } else if (isWindows()) {
+            params.add("cmd.exe");
+            params.add("start");
+            params.add("/C");
+            params.add("chrome");
+        } else {
+            params.add("google-chrome-stable");
+        }
+    }
+
+    private static void addFirefoxCommand(List<String> params) {
+        if (isMacos()) {
+            params.add("/Applications/Firefox.app/Contents/MacOS/firefox");
+            return;
+        }
+        if (isWindows()) {
+            params.add("cmd.exe");
+            params.add("/C");
+            params.add("start");
+        }
+        params.add("firefox");
+    }
+
+    private static boolean isWindows() {
+        return System.getProperty("os.name").toLowerCase().startsWith("windows");
+    }
+
+    private static boolean isMacos() {
+        return System.getProperty("os.name").toLowerCase().startsWith("mac");
+    }
+
+    private static Process browserTemplate(String name, String url, BiConsumer<String, List<String>> paramsBuilder) {
+        File temp;
+        try {
+            temp = File.createTempFile("teavm", "teavm");
+            temp.delete();
+            temp.mkdirs();
+            Runtime.getRuntime().addShutdownHook(new Thread(() -> deleteDir(temp)));
+            System.out.println("Running " + name + " with user data dir: " + temp.getAbsolutePath());
+            List<String> params = new ArrayList<>();
+            paramsBuilder.accept(temp.getAbsolutePath(), params);
+            params.add(url);
+            ProcessBuilder pb = new ProcessBuilder(params.toArray(new String[0]));
+            Process process = pb.start();
+            logStream(process.getInputStream(), name + " stdout");
+            logStream(process.getErrorStream(), name + " stderr");
+            new Thread(() -> {
+                try {
+                    System.out.println(name + " process terminated with code: " + process.waitFor());
+                } catch (InterruptedException e) {
+                    // ignore
+                }
+            });
+            return process;
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static void logStream(InputStream stream, String name) {
+        new Thread(() -> {
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) {
+                while (true) {
+                    String line = reader.readLine();
+                    if (line == null) {
+                        break;
+                    }
+                    System.out.println(name + ": " + line);
+                }
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }).start();
+    }
+
+    private static void deleteDir(File dir) {
+        for (File file : dir.listFiles()) {
+            if (file.isDirectory()) {
+                deleteDir(file);
+            } else {
+                file.delete();
+            }
+        }
+        dir.delete();
+    }
 }
diff --git a/tools/junit/src/main/java/org/teavm/junit/CPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/CPlatformSupport.java
new file mode 100644
index 000000000..307f6698a
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/CPlatformSupport.java
@@ -0,0 +1,96 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static org.teavm.junit.PropertyNames.C_COMPILER;
+import static org.teavm.junit.PropertyNames.C_ENABLED;
+import static org.teavm.junit.PropertyNames.C_LINE_NUMBERS;
+import static org.teavm.junit.PropertyNames.OPTIMIZED;
+import static org.teavm.junit.TestUtil.resourceToFile;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+import org.teavm.backend.c.CTarget;
+import org.teavm.backend.c.generate.CNameProvider;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.ReferenceCache;
+import org.teavm.vm.TeaVM;
+
+class CPlatformSupport extends TestPlatformSupport<CTarget> {
+    CPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        super(classSource, referenceCache);
+    }
+
+    @Override
+    TestRunStrategy createRunStrategy(File outputDir) {
+        String cCommand = System.getProperty(C_COMPILER);
+        if (cCommand != null) {
+            return new CRunStrategy(cCommand);
+        }
+        return null;
+    }
+
+    @Override
+    TestPlatform getPlatform() {
+        return TestPlatform.C;
+    }
+
+    @Override
+    String getPath() {
+        return "c";
+    }
+
+    @Override
+    String getExtension() {
+        return "";
+    }
+
+    @Override
+    List<TeaVMTestConfiguration<CTarget>> getConfigurations() {
+        List<TeaVMTestConfiguration<CTarget>> configurations = new ArrayList<>();
+        if (Boolean.getBoolean(C_ENABLED)) {
+            configurations.add(TeaVMTestConfiguration.C_DEFAULT);
+            if (Boolean.getBoolean(OPTIMIZED)) {
+                configurations.add(TeaVMTestConfiguration.C_OPTIMIZED);
+            }
+        }
+        return configurations;
+    }
+
+    @Override
+    CompileResult compile(Consumer<TeaVM> additionalProcessing, String baseName,
+            TeaVMTestConfiguration<CTarget> configuration, File path) {
+        CompilePostProcessor postBuild = (vm, file) -> {
+            try {
+                resourceToFile("teavm-CMakeLists.txt", new File(file.getParent(), "CMakeLists.txt"),
+                        Collections.emptyMap());
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        };
+        return compile(configuration, this::createCTarget, TestNativeEntryPoint.class.getName(), path, ".c",
+                postBuild, true, additionalProcessing, baseName);
+    }
+
+    private CTarget createCTarget() {
+        CTarget cTarget = new CTarget(new CNameProvider());
+        cTarget.setLineNumbersGenerated(Boolean.parseBoolean(System.getProperty(C_LINE_NUMBERS, "false")));
+        return cTarget;
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/CompilePostProcessor.java b/tools/junit/src/main/java/org/teavm/junit/CompilePostProcessor.java
new file mode 100644
index 000000000..894930c64
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/CompilePostProcessor.java
@@ -0,0 +1,23 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import java.io.File;
+import org.teavm.vm.TeaVM;
+
+interface CompilePostProcessor {
+    void process(TeaVM vm, File targetFile);
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/CompileResult.java b/tools/junit/src/main/java/org/teavm/junit/CompileResult.java
new file mode 100644
index 000000000..776242598
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/CompileResult.java
@@ -0,0 +1,25 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import java.io.File;
+
+class CompileResult {
+    boolean success = true;
+    String errorMessage;
+    File file;
+    Throwable throwable;
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/EachTestCompiledSeparately.java b/tools/junit/src/main/java/org/teavm/junit/EachTestCompiledSeparately.java
new file mode 100644
index 000000000..919de4a73
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/EachTestCompiledSeparately.java
@@ -0,0 +1,26 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface EachTestCompiledSeparately {
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/JSPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/JSPlatformSupport.java
new file mode 100644
index 000000000..352d2096b
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/JSPlatformSupport.java
@@ -0,0 +1,150 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.teavm.junit.PropertyNames.JS_DECODE_STACK;
+import static org.teavm.junit.PropertyNames.JS_ENABLED;
+import static org.teavm.junit.PropertyNames.JS_RUNNER;
+import static org.teavm.junit.PropertyNames.MINIFIED;
+import static org.teavm.junit.PropertyNames.OPTIMIZED;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.teavm.backend.javascript.JavaScriptTarget;
+import org.teavm.debugging.information.DebugInformation;
+import org.teavm.debugging.information.DebugInformationBuilder;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.MethodReference;
+import org.teavm.model.ReferenceCache;
+import org.teavm.vm.TeaVM;
+
+class JSPlatformSupport extends TestPlatformSupport<JavaScriptTarget> {
+    JSPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        super(classSource, referenceCache);
+    }
+
+    @Override
+    TestRunStrategy createRunStrategy(File outputDir) {
+        String runStrategyName = System.getProperty(JS_RUNNER);
+        if (runStrategyName != null) {
+            switch (runStrategyName) {
+                case "browser":
+                    return new BrowserRunStrategy(outputDir, "JAVASCRIPT", BrowserRunStrategy::customBrowser);
+                case "browser-chrome":
+                    return new BrowserRunStrategy(outputDir, "JAVASCRIPT", BrowserRunStrategy::chromeBrowser);
+                case "browser-firefox":
+                    return new BrowserRunStrategy(outputDir, "JAVASCRIPT", BrowserRunStrategy::firefoxBrowser);
+                case "none":
+                    return null;
+                default:
+                    throw new RuntimeException("Unknown run strategy: " + runStrategyName);
+            }
+        }
+        return null;
+    }
+
+    @Override
+    TestPlatform getPlatform() {
+        return TestPlatform.JAVASCRIPT;
+    }
+
+    @Override
+    String getPath() {
+        return "js";
+    }
+
+    @Override
+    String getExtension() {
+        return ".js";
+    }
+
+    @Override
+    List<TeaVMTestConfiguration<JavaScriptTarget>> getConfigurations() {
+        List<TeaVMTestConfiguration<JavaScriptTarget>> configurations = new ArrayList<>();
+        if (Boolean.parseBoolean(System.getProperty(JS_ENABLED, "true"))) {
+            configurations.add(TeaVMTestConfiguration.JS_DEFAULT);
+            if (Boolean.getBoolean(MINIFIED)) {
+                configurations.add(TeaVMTestConfiguration.JS_MINIFIED);
+            }
+            if (Boolean.getBoolean(OPTIMIZED)) {
+                configurations.add(TeaVMTestConfiguration.JS_OPTIMIZED);
+            }
+        }
+        return configurations;
+    }
+
+    @Override
+    CompileResult compile(Consumer<TeaVM> additionalProcessing, String baseName,
+            TeaVMTestConfiguration<JavaScriptTarget> configuration, File path) {
+        boolean decodeStack = Boolean.parseBoolean(System.getProperty(JS_DECODE_STACK, "true"));
+        var debugEmitter = new DebugInformationBuilder(new ReferenceCache());
+        Supplier<JavaScriptTarget> targetSupplier = () -> {
+            JavaScriptTarget target = new JavaScriptTarget();
+            target.setStrict(true);
+            if (decodeStack) {
+                target.setDebugEmitter(debugEmitter);
+                target.setStackTraceIncluded(true);
+            }
+            return target;
+        };
+        CompilePostProcessor postBuild = null;
+        if (decodeStack) {
+            postBuild = (vm, file) -> {
+                DebugInformation debugInfo = debugEmitter.getDebugInformation();
+                File sourceMapsFile = new File(file.getPath() + ".map");
+                File debugFile = new File(file.getPath() + ".teavmdbg");
+                try {
+                    try (Writer writer = new OutputStreamWriter(new FileOutputStream(file, true), UTF_8)) {
+                        writer.write("\n//# sourceMappingURL=");
+                        writer.write(sourceMapsFile.getName());
+                    }
+
+                    try (Writer sourceMapsOut = new OutputStreamWriter(new FileOutputStream(sourceMapsFile), UTF_8)) {
+                        debugInfo.writeAsSourceMaps(sourceMapsOut, "", file.getPath());
+                    }
+
+                    try (OutputStream out = new FileOutputStream(debugFile)) {
+                        debugInfo.write(out);
+                    }
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            };
+        }
+        return compile(configuration, targetSupplier, TestJsEntryPoint.class.getName(), path, ".js",
+                postBuild, false, additionalProcessing, baseName);
+    }
+
+    @Override
+    void additionalOutput(File outputPath, File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+        htmlOutput(outputPath, outputPathForMethod, configuration, reference, "teavm-run-test.html");
+    }
+
+    @Override
+    void additionalSingleTestOutput(File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+        htmlSingleTestOutput(outputPathForMethod, configuration, "teavm-run-test.html");
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/OnlyPlatform.java b/tools/junit/src/main/java/org/teavm/junit/OnlyPlatform.java
new file mode 100644
index 000000000..3ae33839d
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/OnlyPlatform.java
@@ -0,0 +1,27 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.METHOD })
+public @interface OnlyPlatform {
+    TestPlatform[] value();
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/PropertyNames.java b/tools/junit/src/main/java/org/teavm/junit/PropertyNames.java
new file mode 100644
index 000000000..8824743b4
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/PropertyNames.java
@@ -0,0 +1,37 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+final class PropertyNames {
+    static final String PATH_PARAM = "teavm.junit.target";
+    static final String JS_RUNNER = "teavm.junit.js.runner";
+    static final String WASM_RUNNER = "teavm.junit.wasm.runner";
+    static final String THREAD_COUNT = "teavm.junit.threads";
+    static final String JS_ENABLED = "teavm.junit.js";
+    static final String JS_DECODE_STACK = "teavm.junit.js.decodeStack";
+    static final String C_ENABLED = "teavm.junit.c";
+    static final String WASM_ENABLED = "teavm.junit.wasm";
+    static final String WASI_ENABLED = "teavm.junit.wasi";
+    static final String WASI_RUNNER = "teavm.junit.wasi.runner";
+    static final String C_COMPILER = "teavm.junit.c.compiler";
+    static final String C_LINE_NUMBERS = "teavm.junit.c.lineNumbers";
+    static final String MINIFIED = "teavm.junit.minified";
+    static final String OPTIMIZED = "teavm.junit.optimized";
+    static final String SOURCE_DIRS = "teavm.junit.sourceDirs";
+
+    private PropertyNames() {
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/SkipPlatform.java b/tools/junit/src/main/java/org/teavm/junit/SkipPlatform.java
new file mode 100644
index 000000000..45f85e766
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/SkipPlatform.java
@@ -0,0 +1,27 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.METHOD })
+public @interface SkipPlatform {
+    TestPlatform[] value();
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java b/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java
index 8ac400511..2729c714a 100644
--- a/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java
+++ b/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java
@@ -15,17 +15,16 @@
  */
 package org.teavm.junit;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.teavm.junit.PropertyNames.PATH_PARAM;
+import static org.teavm.junit.TestUtil.getOutputFile;
 import java.io.BufferedOutputStream;
-import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.Writer;
+import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -39,13 +38,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
-import java.util.StringTokenizer;
-import java.util.function.BiConsumer;
 import java.util.function.Consumer;
-import java.util.function.Supplier;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import junit.framework.TestCase;
-import org.apache.commons.io.IOUtils;
 import org.junit.runner.Description;
 import org.junit.runner.Runner;
 import org.junit.runner.manipulation.Filter;
@@ -54,20 +50,6 @@ import org.junit.runner.manipulation.NoTestsRemainException;
 import org.junit.runner.notification.Failure;
 import org.junit.runner.notification.RunNotifier;
 import org.junit.runners.model.InitializationError;
-import org.teavm.backend.c.CTarget;
-import org.teavm.backend.c.generate.CNameProvider;
-import org.teavm.backend.javascript.JavaScriptTarget;
-import org.teavm.backend.wasm.WasmRuntimeType;
-import org.teavm.backend.wasm.WasmTarget;
-import org.teavm.backend.wasm.generate.DirectorySourceFileResolver;
-import org.teavm.callgraph.CallGraph;
-import org.teavm.debugging.information.DebugInformation;
-import org.teavm.debugging.information.DebugInformationBuilder;
-import org.teavm.dependency.DependencyAnalyzerFactory;
-import org.teavm.dependency.FastDependencyAnalyzer;
-import org.teavm.dependency.PreciseDependencyAnalyzer;
-import org.teavm.diagnostics.DefaultProblemTextConsumer;
-import org.teavm.diagnostics.Problem;
 import org.teavm.model.AnnotationHolder;
 import org.teavm.model.AnnotationReader;
 import org.teavm.model.AnnotationValue;
@@ -81,11 +63,7 @@ import org.teavm.model.PreOptimizingClassHolderSource;
 import org.teavm.model.ReferenceCache;
 import org.teavm.model.ValueType;
 import org.teavm.parsing.ClasspathClassHolderSource;
-import org.teavm.tooling.TeaVMProblemRenderer;
-import org.teavm.vm.DirectoryBuildTarget;
 import org.teavm.vm.TeaVM;
-import org.teavm.vm.TeaVMBuilder;
-import org.teavm.vm.TeaVMOptimizationLevel;
 import org.teavm.vm.TeaVMTarget;
 
 public class TeaVMTestRunner extends Runner implements Filterable {
@@ -101,22 +79,6 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     static final String JUNIT4_AFTER = "org.junit.After";
     static final String TESTNG_AFTER = "org.testng.annotations.AfterMethod";
     static final String TESTNG_PROVIDER = "org.testng.annotations.DataProvider";
-    private static final String PATH_PARAM = "teavm.junit.target";
-    private static final String JS_RUNNER = "teavm.junit.js.runner";
-    private static final String WASM_RUNNER = "teavm.junit.wasm.runner";
-    private static final String THREAD_COUNT = "teavm.junit.threads";
-    private static final String JS_ENABLED = "teavm.junit.js";
-    static final String JS_DECODE_STACK = "teavm.junit.js.decodeStack";
-    private static final String C_ENABLED = "teavm.junit.c";
-    private static final String WASM_ENABLED = "teavm.junit.wasm";
-    private static final String WASI_ENABLED = "teavm.junit.wasi";
-    private static final String WASI_RUNNER = "teavm.junit.wasi.runner";
-    private static final String C_COMPILER = "teavm.junit.c.compiler";
-    private static final String C_LINE_NUMBERS = "teavm.junit.c.lineNumbers";
-    private static final String MINIFIED = "teavm.junit.minified";
-    private static final String OPTIMIZED = "teavm.junit.optimized";
-    private static final String FAST_ANALYSIS = "teavm.junit.fastAnalysis";
-    private static final String SOURCE_DIRS = "teavm.junit.sourceDirs";
 
     private Class<?> testClass;
     private boolean isWholeClassCompilation;
@@ -125,11 +87,13 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     private Description suiteDescription;
     private static File outputDir;
     private Map<Method, Description> descriptions = new HashMap<>();
-    private static Map<RunKind, TestRunStrategy> runners = new HashMap<>();
+    private static Map<TestPlatform, TestRunStrategy> runners = new HashMap<>();
     private List<Method> filteredChildren;
     private static ReferenceCache referenceCache = new ReferenceCache();
     private boolean classCompilationOk;
     private List<TestRun> runsInCurrentClass = new ArrayList<>();
+    private static List<TestPlatformSupport<?>> platforms = new ArrayList<>();
+    private List<TestPlatformSupport<?>> participatingPlatforms = new ArrayList<>();
 
     static {
         classLoader = TeaVMTestRunner.class.getClassLoader();
@@ -140,55 +104,14 @@ public class TeaVMTestRunner extends Runner implements Filterable {
             outputDir = new File(outputPath);
         }
 
-        String runStrategyName = System.getProperty(JS_RUNNER);
-        if (runStrategyName != null) {
-            TestRunStrategy jsRunStrategy;
-            switch (runStrategyName) {
-                case "browser":
-                    jsRunStrategy = new BrowserRunStrategy(outputDir, "JAVASCRIPT", TeaVMTestRunner::customBrowser);
-                    break;
-                case "browser-chrome":
-                    jsRunStrategy = new BrowserRunStrategy(outputDir, "JAVASCRIPT", TeaVMTestRunner::chromeBrowser);
-                    break;
-                case "browser-firefox":
-                    jsRunStrategy = new BrowserRunStrategy(outputDir, "JAVASCRIPT", TeaVMTestRunner::firefoxBrowser);
-                    break;
-                case "none":
-                    jsRunStrategy = null;
-                    break;
-                default:
-                    throw new RuntimeException("Unknown run strategy: " + runStrategyName);
-            }
-            runners.put(RunKind.JAVASCRIPT, jsRunStrategy);
-        }
+        platforms.add(new JSPlatformSupport(classSource, referenceCache));
+        platforms.add(new WebAssemblyPlatformSupport(classSource, referenceCache));
+        platforms.add(new WasiPlatformSupport(classSource, referenceCache));
+        platforms.add(new CPlatformSupport(classSource, referenceCache));
 
-        String cCommand = System.getProperty(C_COMPILER);
-        if (cCommand != null) {
-            runners.put(RunKind.C, new CRunStrategy(cCommand));
-        }
-        String wasiCommand = System.getProperty(WASI_RUNNER);
-        if (wasiCommand != null) {
-            runners.put(RunKind.WASI, new WasiRunStrategy(wasiCommand));
-        }
-
-        runStrategyName = System.getProperty(WASM_RUNNER);
-        if (runStrategyName != null) {
-            TestRunStrategy wasmRunStrategy;
-            switch (runStrategyName) {
-                case "browser":
-                    wasmRunStrategy = new BrowserRunStrategy(outputDir, "WASM", TeaVMTestRunner::customBrowser);
-                    break;
-                case "chrome":
-                case "browser-chrome":
-                    wasmRunStrategy = new BrowserRunStrategy(outputDir, "WASM", TeaVMTestRunner::chromeBrowser);
-                    break;
-                case "browser-firefox":
-                    wasmRunStrategy = new BrowserRunStrategy(outputDir, "WASM", TeaVMTestRunner::firefoxBrowser);
-                    break;
-                default:
-                    throw new RuntimeException("Unknown run strategy: " + runStrategyName);
-            }
-            runners.put(RunKind.WASM, wasmRunStrategy);
+        for (var platform : platforms) {
+            var runStrategy = platform.createRunStrategy(outputDir);
+            runners.put(platform.getPlatform(), runStrategy);
         }
 
         for (var strategy : runners.values()) {
@@ -206,126 +129,6 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         this.testClass = testClass;
     }
 
-    private static Process customBrowser(String url) {
-        System.out.println("Open link to run tests: " + url + "?logging=true");
-        return null;
-    }
-
-    private static Process chromeBrowser(String url) {
-        return browserTemplate("chrome", url, (profile, params) -> {
-            addChromeCommand(params);
-            params.addAll(Arrays.asList(
-                    "--headless",
-                    "--disable-gpu",
-                    "--remote-debugging-port=9222",
-                    "--no-first-run",
-                    "--user-data-dir=" + profile
-            ));
-        });
-    }
-
-    private static Process firefoxBrowser(String url) {
-        return browserTemplate("firefox", url, (profile, params) -> {
-            addFirefoxCommand(params);
-            params.addAll(Arrays.asList(
-                    "--headless",
-                    "--profile",
-                    profile
-            ));
-        });
-    }
-
-    private static void addChromeCommand(List<String> params) {
-        if (isMacos()) {
-            params.add("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
-        } else if (isWindows()) {
-            params.add("cmd.exe");
-            params.add("start");
-            params.add("/C");
-            params.add("chrome");
-        } else {
-            params.add("google-chrome-stable");
-        }
-    }
-
-    private static void addFirefoxCommand(List<String> params) {
-        if (isMacos()) {
-            params.add("/Applications/Firefox.app/Contents/MacOS/firefox");
-            return;
-        }
-        if (isWindows()) {
-            params.add("cmd.exe");
-            params.add("/C");
-            params.add("start");
-        }
-        params.add("firefox");
-    }
-
-    private static boolean isWindows() {
-        return System.getProperty("os.name").toLowerCase().startsWith("windows");
-    }
-
-    private static boolean isMacos() {
-        return System.getProperty("os.name").toLowerCase().startsWith("mac");
-    }
-
-    private static Process browserTemplate(String name, String url, BiConsumer<String, List<String>> paramsBuilder) {
-        File temp;
-        try {
-            temp = File.createTempFile("teavm", "teavm");
-            temp.delete();
-            temp.mkdirs();
-            Runtime.getRuntime().addShutdownHook(new Thread(() -> deleteDir(temp)));
-            System.out.println("Running " + name + " with user data dir: " + temp.getAbsolutePath());
-            List<String> params = new ArrayList<>();
-            paramsBuilder.accept(temp.getAbsolutePath(), params);
-            int tabs = Integer.parseInt(System.getProperty(THREAD_COUNT, "1"));
-            for (int i = 0; i < tabs; ++i) {
-                params.add(url);
-            }
-            ProcessBuilder pb = new ProcessBuilder(params.toArray(new String[0]));
-            Process process = pb.start();
-            logStream(process.getInputStream(), name + " stdout");
-            logStream(process.getErrorStream(), name + " stderr");
-            new Thread(() -> {
-                try {
-                    System.out.println(name + " process terminated with code: " + process.waitFor());
-                } catch (InterruptedException e) {
-                    // ignore
-                }
-            });
-            return process;
-        } catch (IOException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    private static void logStream(InputStream stream, String name) {
-        new Thread(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) {
-                while (true) {
-                    String line = reader.readLine();
-                    if (line == null) {
-                        break;
-                    }
-                    System.out.println(name + ": " + line);
-                }
-            } catch (IOException e) {
-                e.printStackTrace();
-            }
-        }).start();
-    }
-
-    private static void deleteDir(File dir) {
-        for (File file : dir.listFiles()) {
-            if (file.isDirectory()) {
-                deleteDir(file);
-            } else {
-                file.delete();
-            }
-        }
-        dir.delete();
-    }
 
     @Override
     public Description getDescription() {
@@ -340,11 +143,17 @@ public class TeaVMTestRunner extends Runner implements Filterable {
 
     @Override
     public void run(RunNotifier notifier) {
+        for (var platform : platforms) {
+            if (!platform.getConfigurations().isEmpty()) {
+                participatingPlatforms.add(platform);
+            }
+        }
+
         List<Method> children = getFilteredChildren();
         var description = getDescription();
 
         notifier.fireTestStarted(description);
-        isWholeClassCompilation = testClass.isAnnotationPresent(WholeClassCompilation.class);
+        isWholeClassCompilation = !testClass.isAnnotationPresent(EachTestCompiledSeparately.class);
         if (isWholeClassCompilation) {
             classCompilationOk = compileWholeClass(children, notifier);
         }
@@ -402,45 +211,69 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     }
 
     private boolean compileWholeClass(List<Method> children, RunNotifier notifier) {
-        File outputPath = getOutputPathForClass();
-        boolean hasErrors = false;
         Description description = getDescription();
 
-        for (TeaVMTestConfiguration<JavaScriptTarget> configuration : getJavaScriptConfigurations()) {
-            CompileResult result = compileToJs(wholeClass(children), "classTest", configuration, outputPath);
-            if (!result.success) {
-                hasErrors = true;
-                notifier.fireTestFailure(createFailure(description, result));
+        for (var platformSupport : participatingPlatforms) {
+            if (!compileClassForPlatform(platformSupport, children, description, notifier)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @SuppressWarnings("unchecked")
+    private boolean compileClassForPlatform(TestPlatformSupport<?> platform, List<Method> children,
+            Description description, RunNotifier notifier) {
+        if (hasChildrenToRun(children, platform.getPlatform())) {
+            for (var configuration : platform.getConfigurations()) {
+                var path = getOutputPathForClass(platform);
+                var castPlatform = (TestPlatformSupport<TeaVMTarget>) platform;
+                var castConfiguration = (TeaVMTestConfiguration<TeaVMTarget>) configuration;
+                var result = castPlatform.compile(wholeClass(children, platform.getPlatform()), "classTest",
+                        castConfiguration, path);
+                if (!result.success) {
+                    notifier.fireTestFailure(createFailure(description, result));
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    private boolean isPlatformPresent(AnnotatedElement declaration, TestPlatform platform) {
+        var skipPlatform = declaration.getAnnotation(SkipPlatform.class);
+        if (skipPlatform != null) {
+            for (var toSkip : skipPlatform.value()) {
+                if (toSkip == platform) {
+                    return false;
+                }
             }
         }
 
-        for (TeaVMTestConfiguration<CTarget> configuration : getCConfigurations()) {
-            CompileResult result = compileToC(wholeClass(children), "classTest", configuration, outputPath);
-            if (!result.success) {
-                hasErrors = true;
-                notifier.fireTestFailure(createFailure(description, result));
+        var onlyPlatform = declaration.getAnnotation(OnlyPlatform.class);
+        if (onlyPlatform != null) {
+            for (var allowedPlatform : onlyPlatform.value()) {
+                if (allowedPlatform == platform) {
+                    return true;
+                }
             }
+            return false;
         }
 
-        for (TeaVMTestConfiguration<WasmTarget> configuration : getWasmConfigurations()) {
-            CompileResult result = compileToWasm(WasmRuntimeType.TEAVM, wholeClass(children), "classTest",
-                    configuration, outputPath);
-            if (!result.success) {
-                hasErrors = true;
-                notifier.fireTestFailure(createFailure(description, result));
-            }
-        }
+        return true;
+    }
 
-        for (TeaVMTestConfiguration<WasmTarget> configuration : getWasiConfigurations()) {
-            CompileResult result = compileToWasm(WasmRuntimeType.WASI, wholeClass(children), "classTest",
-                    configuration, outputPath);
-            if (!result.success) {
-                hasErrors = true;
-                notifier.fireTestFailure(createFailure(description, result));
-            }
-        }
+    private boolean hasChildrenToRun(List<Method> children, TestPlatform platform) {
+        return isPlatformPresent(testClass, platform)
+                && children.stream().anyMatch(child -> isPlatformPresent(child, platform));
+    }
 
-        return !hasErrors;
+    private List<Method> filterChildren(List<Method> children, TestPlatform platform) {
+        return children.stream().filter(child -> isPlatformPresent(child, platform)).collect(Collectors.toList());
+    }
+
+    private boolean shouldRunChild(Method child, TestPlatform platform) {
+        return isPlatformPresent(testClass, platform) && isPlatformPresent(child, platform);
     }
 
     private void runChild(Method child, RunNotifier notifier) {
@@ -496,123 +329,49 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     }
 
     private void prepareTestsFromWholeClass(Method child, List<TestRun> runs) {
-        File outputPath = getOutputPathForClass();
-        File outputPathForMethod = getOutputPath(child);
         MethodDescriptor descriptor = getDescriptor(child);
         MethodReference reference = new MethodReference(child.getDeclaringClass().getName(), descriptor);
 
-        File testFilePath = getOutputPath(child);
-        testFilePath.mkdirs();
-
-        Map<String, String> properties = new HashMap<>();
-        for (var configuration : getJavaScriptConfigurations()) {
-            File testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), false, ".js");
-            runs.add(createTestRun(configuration, testPath, child, RunKind.JAVASCRIPT, reference.toString()));
-            File htmlPath = getOutputFile(outputPathForMethod, "test", configuration.getSuffix(), false, ".html");
-            properties.put("SCRIPT", "../" + testPath.getName());
-            properties.put("IDENTIFIER", reference.toString());
-            try {
-                resourceToFile("teavm-run-test.html", htmlPath, properties);
-            } catch (IOException e) {
-                throw new RuntimeException(e);
+        for (var platform : participatingPlatforms) {
+            if (shouldRunChild(child, platform.getPlatform())) {
+                var outputPath = getOutputPathForClass(platform);
+                var outputPathForMethod = getOutputPath(child, platform);
+                for (var configuration : platform.getConfigurations()) {
+                    var testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), false,
+                            platform.getExtension());
+                    runs.add(createTestRun(configuration, testPath, child, platform.getPlatform(),
+                            reference.toString()));
+                    platform.additionalOutput(outputPath, outputPathForMethod, configuration, reference);
+                }
             }
         }
-
-        for (var configuration : getWasmConfigurations()) {
-            File testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), false, ".wasm");
-            runs.add(createTestRun(configuration, testPath, child, RunKind.WASM, reference.toString()));
-            File htmlPath = getOutputFile(outputPathForMethod, "test-wasm", configuration.getSuffix(), false, ".html");
-            properties.put("SCRIPT", "../" + testPath.getName());
-            properties.put("IDENTIFIER", reference.toString());
-            try {
-                resourceToFile("teavm-run-test-wasm.html", htmlPath, properties);
-            } catch (IOException e) {
-                throw new RuntimeException(e);
-            }
-        }
-
-        for (var configuration : getWasiConfigurations()) {
-            File testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), false, ".wasm");
-            runs.add(createTestRun(configuration, testPath, child, RunKind.WASI, reference.toString()));
-            File htmlPath = getOutputFile(outputPathForMethod, "test-wasm", configuration.getSuffix(), false, ".html");
-            properties.put("SCRIPT", "../" + testPath.getName());
-            properties.put("IDENTIFIER", reference.toString());
-        }
-
-        for (var configuration : getCConfigurations()) {
-            File testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), true, ".c");
-            runs.add(createTestRun(configuration, testPath, child, RunKind.C, reference.toString()));
-        }
     }
 
     private void prepareCompiledTest(Method child, RunNotifier notifier, List<TestRun> runs) {
+        MethodDescriptor descriptor = getDescriptor(child);
+        MethodReference reference = new MethodReference(child.getDeclaringClass().getName(), descriptor);
+
         try {
-            File outputPath = getOutputPath(child);
-
-            Map<String, String> properties = new HashMap<>();
-            for (var configuration : getJavaScriptConfigurations()) {
-                CompileResult compileResult = compileToJs(singleTest(child), "test", configuration, outputPath);
-                TestRun run = prepareRun(configuration, child, compileResult, notifier, RunKind.JAVASCRIPT);
-                if (run != null) {
-                    runs.add(run);
-
-                    File testPath = getOutputFile(outputPath, "test", configuration.getSuffix(), false, ".js");
-                    File htmlPath = getOutputFile(outputPath, "test", configuration.getSuffix(), false, ".html");
-                    properties.put("SCRIPT", testPath.getName());
-                    properties.put("IDENTIFIER", "");
-
-                    try {
-                        resourceToFile("teavm-run-test.html", htmlPath, properties);
-                    } catch (IOException e) {
-                        throw new RuntimeException(e);
+            for (var platform : participatingPlatforms) {
+                if (shouldRunChild(child, platform.getPlatform())) {
+                    File outputPath = getOutputPath(child, platform);
+                    for (var configuration : platform.getConfigurations()) {
+                        @SuppressWarnings("unchecked")
+                        var castPlatform = (TestPlatformSupport<TeaVMTarget>) platform;
+                        @SuppressWarnings("unchecked")
+                        var castConfig = (TeaVMTestConfiguration<TeaVMTarget>) configuration;
+                        var compileResult = castPlatform.compile(singleTest(child), "test", castConfig, outputPath);
+                        var run = prepareRun(configuration, child, compileResult, notifier, platform.getPlatform());
+                        if (run != null) {
+                            runs.add(run);
+                            platform.additionalSingleTestOutput(outputPath, configuration, reference);
+                        }
                     }
                 }
             }
-
-            for (var configuration : getCConfigurations()) {
-                CompileResult compileResult = compileToC(singleTest(child), "test", configuration, outputPath);
-                TestRun run = prepareRun(configuration, child, compileResult, notifier, RunKind.C);
-                if (run != null) {
-                    runs.add(run);
-                }
-            }
-
-            for (var configuration : getWasmConfigurations()) {
-                CompileResult compileResult = compileToWasm(WasmRuntimeType.TEAVM, singleTest(child),
-                        "test", configuration, outputPath);
-                TestRun run = prepareRun(configuration, child, compileResult, notifier, RunKind.WASM);
-                if (run != null) {
-                    runs.add(run);
-
-                    File testPath = getOutputFile(outputPath, "test", configuration.getSuffix(), false, ".wasm");
-                    File htmlPath = getOutputFile(outputPath, "test", configuration.getSuffix(), false, ".html");
-                    properties.put("SCRIPT", testPath.getName());
-                    properties.put("IDENTIFIER", "");
-
-                    try {
-                        resourceToFile("teavm-run-test-wasm.html", htmlPath, properties);
-                    } catch (IOException e) {
-                        throw new RuntimeException(e);
-                    }
-                }
-            }
-
-            for (var configuration : getWasiConfigurations()) {
-                CompileResult compileResult = compileToWasm(WasmRuntimeType.WASI, singleTest(child), "test",
-                        configuration, outputPath);
-                TestRun run = prepareRun(configuration, child, compileResult, notifier, RunKind.WASI);
-                if (run != null) {
-                    runs.add(run);
-
-                    File testPath = getOutputFile(outputPath, "test", configuration.getSuffix(), false, ".wasm");
-                    properties.put("SCRIPT", testPath.getName());
-                    properties.put("IDENTIFIER", "");
-                }
-            }
         } catch (Throwable e) {
             notifier.fireTestFailure(new Failure(describeChild(child), e));
             notifier.fireTestFinished(describeChild(child));
-            return;
         }
     }
 
@@ -939,7 +698,7 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     }
 
     private TestRun prepareRun(TeaVMTestConfiguration<?> configuration, Method child, CompileResult result,
-            RunNotifier notifier, RunKind kind) {
+            RunNotifier notifier, TestPlatform kind) {
         Description description = describeChild(child);
 
         if (!result.success) {
@@ -951,7 +710,7 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         return createTestRun(configuration, result.file, child, kind, null);
     }
 
-    private TestRun createTestRun(TeaVMTestConfiguration<?> configuration, File file, Method child, RunKind kind,
+    private TestRun createTestRun(TeaVMTestConfiguration<?> configuration, File file, Method child, TestPlatform kind,
             String argument) {
         return new TestRun(generateName(child.getName(), configuration), file.getParentFile(), child,
                 file.getName(), kind, argument);
@@ -984,107 +743,21 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         return true;
     }
 
-    private File getOutputPath(Method method) {
+    private File getOutputPath(Method method, TestPlatformSupport<?> platform) {
         File path = outputDir;
-        path = new File(path, testClass.getName().replace('.', '/'));
+        path = new File(new File(path, platform.getPath()), testClass.getName().replace('.', '/'));
         path = new File(path, method.getName());
         path.mkdirs();
         return path;
     }
 
-    private File getOutputPathForClass() {
+    private File getOutputPathForClass(TestPlatformSupport<?> platform) {
         File path = outputDir;
-        path = new File(path, testClass.getName().replace('.', '/'));
+        path = new File(new File(path, platform.getPath()), testClass.getName().replace('.', '/'));
         path.mkdirs();
         return path;
     }
 
-    private CompileResult compileToJs(Consumer<TeaVM> additionalProcessing, String baseName,
-            TeaVMTestConfiguration<JavaScriptTarget> configuration, File path) {
-        boolean decodeStack = Boolean.parseBoolean(System.getProperty(JS_DECODE_STACK, "true"));
-        DebugInformationBuilder debugEmitter = new DebugInformationBuilder(new ReferenceCache());
-        Supplier<JavaScriptTarget> targetSupplier = () -> {
-            JavaScriptTarget target = new JavaScriptTarget();
-            target.setStrict(true);
-            if (decodeStack) {
-                target.setDebugEmitter(debugEmitter);
-                target.setStackTraceIncluded(true);
-            }
-            return target;
-        };
-        CompilePostProcessor postBuild = null;
-        if (decodeStack) {
-            postBuild = (vm, file) -> {
-                DebugInformation debugInfo = debugEmitter.getDebugInformation();
-                File sourceMapsFile = new File(file.getPath() + ".map");
-                File debugFile = new File(file.getPath() + ".teavmdbg");
-                try {
-                    try (Writer writer = new OutputStreamWriter(new FileOutputStream(file, true), UTF_8)) {
-                        writer.write("\n//# sourceMappingURL=");
-                        writer.write(sourceMapsFile.getName());
-                    }
-
-                    try (Writer sourceMapsOut = new OutputStreamWriter(new FileOutputStream(sourceMapsFile), UTF_8)) {
-                        debugInfo.writeAsSourceMaps(sourceMapsOut, "", file.getPath());
-                    }
-
-                    try (OutputStream out = new FileOutputStream(debugFile)) {
-                        debugInfo.write(out);
-                    }
-                } catch (IOException e) {
-                    throw new RuntimeException(e);
-                }
-            };
-        }
-        return compile(configuration, targetSupplier, TestJsEntryPoint.class.getName(), path, ".js",
-                postBuild, false, additionalProcessing, baseName);
-    }
-
-    private CompileResult compileToC(Consumer<TeaVM> additionalProcessing, String baseName,
-            TeaVMTestConfiguration<CTarget> configuration, File path) {
-        CompilePostProcessor postBuild = (vm, file) -> {
-            try {
-                resourceToFile("teavm-CMakeLists.txt", new File(file.getParent(), "CMakeLists.txt"),
-                        Collections.emptyMap());
-            } catch (IOException e) {
-                throw new RuntimeException(e);
-            }
-        };
-        return compile(configuration, this::createCTarget, TestNativeEntryPoint.class.getName(), path, ".c",
-                postBuild, true, additionalProcessing, baseName);
-    }
-
-    private CTarget createCTarget() {
-        CTarget cTarget = new CTarget(new CNameProvider());
-        cTarget.setLineNumbersGenerated(Boolean.parseBoolean(System.getProperty(C_LINE_NUMBERS, "false")));
-        return cTarget;
-    }
-
-    private CompileResult compileToWasm(WasmRuntimeType runtimeType, Consumer<TeaVM> additionalProcessing,
-            String baseName, TeaVMTestConfiguration<WasmTarget> configuration, File path) {
-        Supplier<WasmTarget> targetSupplier = () -> {
-            WasmTarget target = new WasmTarget();
-            target.setRuntimeType(runtimeType);
-            var sourceDirs = System.getProperty(SOURCE_DIRS);
-            if (sourceDirs != null) {
-                var dirs = new ArrayList<File>();
-                for (var tokenizer = new StringTokenizer(sourceDirs, Character.toString(File.pathSeparatorChar));
-                     tokenizer.hasMoreTokens();) {
-                    var dir = new File(tokenizer.nextToken());
-                    if (dir.isDirectory()) {
-                        dirs.add(dir);
-                    }
-                }
-                if (!dirs.isEmpty()) {
-                    target.setSourceFileResolver(new DirectorySourceFileResolver(dirs));
-                }
-            }
-            return target;
-        };
-        return compile(configuration, targetSupplier, TestNativeEntryPoint.class.getName(), path,
-                ".wasm", null, false, additionalProcessing, baseName);
-    }
-
     private Consumer<TeaVM> singleTest(Method method) {
         ClassHolder classHolder = classSource.get(method.getDeclaringClass().getName());
         MethodHolder methodHolder = classHolder.getMethod(getDescriptor(method));
@@ -1097,13 +770,13 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         };
     }
 
-    private Consumer<TeaVM> wholeClass(List<Method> methods) {
+    private Consumer<TeaVM> wholeClass(List<Method> methods, TestPlatform platform) {
         return vm -> {
             Properties properties = new Properties();
             applyProperties(testClass, properties);
             vm.setProperties(properties);
             List<MethodReference> methodReferences = new ArrayList<>();
-            for (Method method : methods) {
+            for (Method method : filterChildren(methods, platform)) {
                 if (isIgnored(method)) {
                     continue;
                 }
@@ -1143,139 +816,6 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         return cls.getAnnotations().get(name);
     }
 
-
-    private <T extends TeaVMTarget> CompileResult compile(TeaVMTestConfiguration<T> configuration,
-            Supplier<T> targetSupplier, String entryPoint, File path, String extension,
-            CompilePostProcessor postBuild, boolean separateDir,
-            Consumer<TeaVM> additionalProcessing, String baseName) {
-        CompileResult result = new CompileResult();
-
-        File outputFile = getOutputFile(path, baseName, configuration.getSuffix(), separateDir, extension);
-        result.file = outputFile;
-
-        ClassLoader classLoader = TeaVMTestRunner.class.getClassLoader();
-
-        T target = targetSupplier.get();
-        configuration.apply(target);
-
-        DependencyAnalyzerFactory dependencyAnalyzerFactory = PreciseDependencyAnalyzer::new;
-        boolean fastAnalysis = Boolean.parseBoolean(System.getProperty(FAST_ANALYSIS));
-        if (fastAnalysis) {
-            dependencyAnalyzerFactory = FastDependencyAnalyzer::new;
-        }
-
-        try {
-            TeaVM vm = new TeaVMBuilder(target)
-                    .setClassLoader(classLoader)
-                    .setClassSource(classSource)
-                    .setReferenceCache(referenceCache)
-                    .setDependencyAnalyzerFactory(dependencyAnalyzerFactory)
-                    .build();
-
-            configuration.apply(vm);
-            additionalProcessing.accept(vm);
-            vm.installPlugins();
-
-            new TestExceptionPlugin().install(vm);
-
-            vm.entryPoint(entryPoint);
-
-            if (fastAnalysis) {
-                vm.setOptimizationLevel(TeaVMOptimizationLevel.SIMPLE);
-                vm.addVirtualMethods(m -> true);
-            }
-            if (!outputFile.getParentFile().exists()) {
-                outputFile.getParentFile().mkdirs();
-            }
-            vm.build(new DirectoryBuildTarget(outputFile.getParentFile()), outputFile.getName());
-            if (!vm.getProblemProvider().getProblems().isEmpty()) {
-                result.success = false;
-                result.errorMessage = buildErrorMessage(vm);
-            } else {
-                if (postBuild != null) {
-                    postBuild.process(vm, outputFile);
-                }
-            }
-
-            return result;
-        } catch (Exception e) {
-            result = new CompileResult();
-            result.success = false;
-            result.throwable = e;
-            return result;
-        }
-    }
-
-    private File getOutputFile(File path, String baseName, String suffix, boolean separateDir, String extension) {
-        StringBuilder simpleName = new StringBuilder();
-        simpleName.append(baseName);
-        if (!suffix.isEmpty()) {
-            if (!separateDir) {
-                simpleName.append('-').append(suffix);
-            }
-        }
-        File outputFile;
-        if (separateDir) {
-            outputFile = new File(new File(path, simpleName.toString()), "test" + extension);
-        } else {
-            simpleName.append(extension);
-            outputFile = new File(path, simpleName.toString());
-        }
-
-        return outputFile;
-    }
-
-    interface CompilePostProcessor {
-        void process(TeaVM vm, File targetFile);
-    }
-
-    private List<TeaVMTestConfiguration<JavaScriptTarget>> getJavaScriptConfigurations() {
-        List<TeaVMTestConfiguration<JavaScriptTarget>> configurations = new ArrayList<>();
-        if (Boolean.parseBoolean(System.getProperty(JS_ENABLED, "true"))) {
-            configurations.add(TeaVMTestConfiguration.JS_DEFAULT);
-            if (Boolean.getBoolean(MINIFIED)) {
-                configurations.add(TeaVMTestConfiguration.JS_MINIFIED);
-            }
-            if (Boolean.getBoolean(OPTIMIZED)) {
-                configurations.add(TeaVMTestConfiguration.JS_OPTIMIZED);
-            }
-        }
-        return configurations;
-    }
-
-    private List<TeaVMTestConfiguration<WasmTarget>> getWasmConfigurations() {
-        List<TeaVMTestConfiguration<WasmTarget>> configurations = new ArrayList<>();
-        if (Boolean.getBoolean(WASM_ENABLED)) {
-            configurations.add(TeaVMTestConfiguration.WASM_DEFAULT);
-            if (Boolean.getBoolean(OPTIMIZED)) {
-                configurations.add(TeaVMTestConfiguration.WASM_OPTIMIZED);
-            }
-        }
-        return configurations;
-    }
-
-    private List<TeaVMTestConfiguration<WasmTarget>> getWasiConfigurations() {
-        List<TeaVMTestConfiguration<WasmTarget>> configurations = new ArrayList<>();
-        if (Boolean.getBoolean(WASI_ENABLED)) {
-            configurations.add(TeaVMTestConfiguration.WASM_DEFAULT);
-            if (Boolean.getBoolean(OPTIMIZED)) {
-                configurations.add(TeaVMTestConfiguration.WASM_OPTIMIZED);
-            }
-        }
-        return configurations;
-    }
-
-    private List<TeaVMTestConfiguration<CTarget>> getCConfigurations() {
-        List<TeaVMTestConfiguration<CTarget>> configurations = new ArrayList<>();
-        if (Boolean.getBoolean(C_ENABLED)) {
-            configurations.add(TeaVMTestConfiguration.C_DEFAULT);
-            if (Boolean.getBoolean(OPTIMIZED)) {
-                configurations.add(TeaVMTestConfiguration.C_OPTIMIZED);
-            }
-        }
-        return configurations;
-    }
-
     private void applyProperties(Class<?> cls, Properties result) {
         if (cls.getSuperclass() != null) {
             applyProperties(cls.getSuperclass(), result);
@@ -1295,70 +835,6 @@ public class TeaVMTestRunner extends Runner implements Filterable {
         return new MethodDescriptor(method.getName(), signature);
     }
 
-    private String buildErrorMessage(TeaVM vm) {
-        CallGraph cg = vm.getDependencyInfo().getCallGraph();
-        DefaultProblemTextConsumer consumer = new DefaultProblemTextConsumer();
-        StringBuilder sb = new StringBuilder();
-        for (Problem problem : vm.getProblemProvider().getProblems()) {
-            consumer.clear();
-            problem.render(consumer);
-            sb.append(consumer.getText());
-            TeaVMProblemRenderer.renderCallStack(cg, problem.getLocation(), sb);
-            sb.append("\n");
-        }
-        return sb.toString();
-    }
-
-    private void resourceToFile(String resource, File file, Map<String, String> properties) throws IOException {
-        if (properties.isEmpty()) {
-            try (InputStream input = TeaVMTestRunner.class.getClassLoader().getResourceAsStream(resource);
-                    OutputStream output = new BufferedOutputStream(new FileOutputStream(file))) {
-                IOUtils.copy(input, output);
-            }
-        } else {
-            String content;
-            try (InputStream input = TeaVMTestRunner.class.getClassLoader().getResourceAsStream(resource)) {
-                content = IOUtils.toString(input, UTF_8);
-            }
-            content = replaceProperties(content, properties);
-            try (OutputStream output = new BufferedOutputStream(new FileOutputStream(file));
-                    Writer writer = new OutputStreamWriter(output)) {
-                 writer.write(content);
-            }
-        }
-    }
-
-    private static String replaceProperties(String s, Map<String, String> properties) {
-        int i = 0;
-        StringBuilder sb = new StringBuilder();
-        while (i < s.length()) {
-            int next = s.indexOf("${", i);
-            if (next < 0) {
-                break;
-            }
-            int end = s.indexOf('}', next + 2);
-            if (end < 0) {
-                break;
-            }
-
-            sb.append(s, i, next);
-            String property = s.substring(next + 2, end);
-            String value = properties.get(property);
-            if (value == null) {
-                sb.append(s, next, end + 1);
-            } else {
-                sb.append(value);
-            }
-            i = end + 1;
-        }
-
-        if (i == 0) {
-            return s;
-        }
-
-        return sb.append(s.substring(i)).toString();
-    }
-
     private static ClassHolderSource getClassSource(ClassLoader classLoader) {
         return new PreOptimizingClassHolderSource(new ClasspathClassHolderSource(classLoader, referenceCache));
     }
@@ -1380,7 +856,19 @@ public class TeaVMTestRunner extends Runner implements Filterable {
             return;
         }
 
-        File outputDir = getOutputPathForClass();
+        for (var platform : participatingPlatforms) {
+            writeRunsDescriptor(platform);
+        }
+    }
+
+    private void writeRunsDescriptor(TestPlatformSupport<?> platform) {
+        var runs = runsInCurrentClass.stream().filter(run -> run.getKind() == platform.getPlatform())
+                .collect(Collectors.toList());
+        if (runs.isEmpty()) {
+            return;
+        }
+
+        File outputDir = getOutputPathForClass(platform);
         outputDir.mkdirs();
         File descriptorFile = new File(outputDir, "tests.json");
         try (OutputStream output = new FileOutputStream(descriptorFile);
@@ -1388,7 +876,7 @@ public class TeaVMTestRunner extends Runner implements Filterable {
                 Writer writer = new OutputStreamWriter(bufferedOutput)) {
             writer.write("[\n");
             boolean first = true;
-            for (TestRun run : runsInCurrentClass.toArray(new TestRun[0])) {
+            for (TestRun run : runs) {
                 if (!first) {
                     writer.write(",\n");
                 }
@@ -1460,11 +948,4 @@ public class TeaVMTestRunner extends Runner implements Filterable {
     private static char hex(int digit) {
         return (char) (digit < 10 ? '0' + digit : 'A' + digit - 10);
     }
-
-    static class CompileResult {
-        boolean success = true;
-        String errorMessage;
-        File file;
-        Throwable throwable;
-    }
 }
diff --git a/tools/junit/src/main/java/org/teavm/junit/RunKind.java b/tools/junit/src/main/java/org/teavm/junit/TestPlatform.java
similarity index 86%
rename from tools/junit/src/main/java/org/teavm/junit/RunKind.java
rename to tools/junit/src/main/java/org/teavm/junit/TestPlatform.java
index d5a26fa04..1f171aefc 100644
--- a/tools/junit/src/main/java/org/teavm/junit/RunKind.java
+++ b/tools/junit/src/main/java/org/teavm/junit/TestPlatform.java
@@ -1,5 +1,5 @@
 /*
- *  Copyright 2018 Alexey Andreev.
+ *  Copyright 2023 Alexey Andreev.
  *
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -15,9 +15,9 @@
  */
 package org.teavm.junit;
 
-enum RunKind {
+public enum TestPlatform {
     JAVASCRIPT,
-    C,
-    WASM,
-    WASI
+    WEBASSEMBLY,
+    WASI,
+    C
 }
diff --git a/tools/junit/src/main/java/org/teavm/junit/TestPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/TestPlatformSupport.java
new file mode 100644
index 000000000..4963af63f
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/TestPlatformSupport.java
@@ -0,0 +1,186 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static org.teavm.junit.TestUtil.resourceToFile;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.teavm.callgraph.CallGraph;
+import org.teavm.dependency.DependencyAnalyzerFactory;
+import org.teavm.dependency.PreciseDependencyAnalyzer;
+import org.teavm.diagnostics.DefaultProblemTextConsumer;
+import org.teavm.diagnostics.Problem;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.MethodReference;
+import org.teavm.model.ReferenceCache;
+import org.teavm.tooling.TeaVMProblemRenderer;
+import org.teavm.vm.DirectoryBuildTarget;
+import org.teavm.vm.TeaVM;
+import org.teavm.vm.TeaVMBuilder;
+import org.teavm.vm.TeaVMTarget;
+
+abstract class TestPlatformSupport<T extends TeaVMTarget> {
+    private ClassHolderSource classSource;
+    private ReferenceCache referenceCache;
+
+    TestPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        this.classSource = classSource;
+        this.referenceCache = referenceCache;
+    }
+
+    abstract TestRunStrategy createRunStrategy(File outputDir);
+
+    abstract TestPlatform getPlatform();
+
+    abstract String getPath();
+
+    abstract String getExtension();
+
+    abstract List<TeaVMTestConfiguration<T>> getConfigurations();
+
+    abstract CompileResult compile(Consumer<TeaVM> additionalProcessing, String baseName,
+            TeaVMTestConfiguration<T> configuration, File path);
+
+    CompileResult compile(TeaVMTestConfiguration<T> configuration,
+            Supplier<T> targetSupplier, String entryPoint, File path, String extension,
+            CompilePostProcessor postBuild, boolean separateDir,
+            Consumer<TeaVM> additionalProcessing, String baseName) {
+        CompileResult result = new CompileResult();
+
+        File outputFile = getOutputFile(path, baseName, configuration.getSuffix(), separateDir, extension);
+        result.file = outputFile;
+
+        ClassLoader classLoader = TeaVMTestRunner.class.getClassLoader();
+
+        var target = targetSupplier.get();
+        configuration.apply(target);
+
+        DependencyAnalyzerFactory dependencyAnalyzerFactory = PreciseDependencyAnalyzer::new;
+
+        try {
+            TeaVM vm = new TeaVMBuilder(target)
+                    .setClassLoader(classLoader)
+                    .setClassSource(classSource)
+                    .setReferenceCache(referenceCache)
+                    .setDependencyAnalyzerFactory(dependencyAnalyzerFactory)
+                    .build();
+
+            configuration.apply(vm);
+            additionalProcessing.accept(vm);
+            vm.installPlugins();
+
+            new TestExceptionPlugin().install(vm);
+
+            vm.entryPoint(entryPoint);
+
+            if (!outputFile.getParentFile().exists()) {
+                outputFile.getParentFile().mkdirs();
+            }
+            vm.build(new DirectoryBuildTarget(outputFile.getParentFile()), outputFile.getName());
+            if (!vm.getProblemProvider().getProblems().isEmpty()) {
+                result.success = false;
+                result.errorMessage = buildErrorMessage(vm);
+            } else {
+                if (postBuild != null) {
+                    postBuild.process(vm, outputFile);
+                }
+            }
+
+            return result;
+        } catch (Exception e) {
+            result = new CompileResult();
+            result.success = false;
+            result.throwable = e;
+            return result;
+        }
+    }
+
+    private File getOutputFile(File path, String baseName, String suffix, boolean separateDir, String extension) {
+        StringBuilder simpleName = new StringBuilder();
+        simpleName.append(baseName);
+        if (!suffix.isEmpty()) {
+            if (!separateDir) {
+                simpleName.append('-').append(suffix);
+            }
+        }
+        File outputFile;
+        if (separateDir) {
+            outputFile = new File(new File(path, simpleName.toString()), "test" + extension);
+        } else {
+            simpleName.append(extension);
+            outputFile = new File(path, simpleName.toString());
+        }
+
+        return outputFile;
+    }
+
+    private String buildErrorMessage(TeaVM vm) {
+        CallGraph cg = vm.getDependencyInfo().getCallGraph();
+        DefaultProblemTextConsumer consumer = new DefaultProblemTextConsumer();
+        StringBuilder sb = new StringBuilder();
+        for (Problem problem : vm.getProblemProvider().getProblems()) {
+            consumer.clear();
+            problem.render(consumer);
+            sb.append(consumer.getText());
+            TeaVMProblemRenderer.renderCallStack(cg, problem.getLocation(), sb);
+            sb.append("\n");
+        }
+        return sb.toString();
+    }
+
+    void additionalOutput(File outputPath, File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+    }
+
+    void additionalSingleTestOutput(File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+    }
+
+    protected final void htmlOutput(File outputPath, File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference, String template) {
+        var testPath = getOutputFile(outputPath, "classTest", configuration.getSuffix(), false,  getExtension());
+        var htmlPath = getOutputFile(outputPathForMethod, "test", configuration.getSuffix(), false, ".html");
+        var properties = Map.of(
+                "SCRIPT", "../" + testPath.getName(),
+                "IDENTIFIER", reference.toString()
+        );
+        try {
+            resourceToFile(template, htmlPath, properties);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    protected final void htmlSingleTestOutput(File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            String template) {
+        File testPath = getOutputFile(outputPathForMethod, "test", configuration.getSuffix(), false, ".wasm");
+        File htmlPath = getOutputFile(outputPathForMethod, "test", configuration.getSuffix(), false, ".html");
+        var properties = Map.of(
+                "SCRIPT", testPath.getName(),
+                "IDENTIFIER", ""
+        );
+
+        try {
+            resourceToFile(template, htmlPath, properties);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/TestRun.java b/tools/junit/src/main/java/org/teavm/junit/TestRun.java
index 8a02cdde2..88e0fa9ad 100644
--- a/tools/junit/src/main/java/org/teavm/junit/TestRun.java
+++ b/tools/junit/src/main/java/org/teavm/junit/TestRun.java
@@ -23,10 +23,10 @@ class TestRun {
     private File baseDirectory;
     private Method method;
     private String fileName;
-    private RunKind kind;
+    private TestPlatform kind;
     private String argument;
 
-    TestRun(String name, File baseDirectory, Method method, String fileName, RunKind kind,
+    TestRun(String name, File baseDirectory, Method method, String fileName, TestPlatform kind,
             String argument) {
         this.name = name;
         this.baseDirectory = baseDirectory;
@@ -52,7 +52,7 @@ class TestRun {
         return fileName;
     }
 
-    public RunKind getKind() {
+    public TestPlatform getKind() {
         return kind;
     }
 
diff --git a/tools/junit/src/main/java/org/teavm/junit/TestUtil.java b/tools/junit/src/main/java/org/teavm/junit/TestUtil.java
new file mode 100644
index 000000000..bba757612
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/TestUtil.java
@@ -0,0 +1,104 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.util.Map;
+import org.apache.commons.io.IOUtils;
+
+final class TestUtil {
+    private TestUtil() {
+    }
+
+    static File getOutputFile(File path, String baseName, String suffix, boolean separateDir, String extension) {
+        StringBuilder simpleName = new StringBuilder();
+        simpleName.append(baseName);
+        if (!suffix.isEmpty()) {
+            if (!separateDir) {
+                simpleName.append('-').append(suffix);
+            }
+        }
+        File outputFile;
+        if (separateDir) {
+            outputFile = new File(new File(path, simpleName.toString()), "test" + extension);
+        } else {
+            simpleName.append(extension);
+            outputFile = new File(path, simpleName.toString());
+        }
+
+        return outputFile;
+    }
+
+    static void resourceToFile(String resource, File file, Map<String, String> properties) throws IOException {
+        file.getParentFile().mkdirs();
+        if (properties.isEmpty()) {
+            try (InputStream input = TeaVMTestRunner.class.getClassLoader().getResourceAsStream(resource);
+                    OutputStream output = new BufferedOutputStream(new FileOutputStream(file))) {
+                IOUtils.copy(input, output);
+            }
+        } else {
+            String content;
+            try (InputStream input = TeaVMTestRunner.class.getClassLoader().getResourceAsStream(resource)) {
+                content = IOUtils.toString(input, UTF_8);
+            }
+            content = replaceProperties(content, properties);
+            try (OutputStream output = new BufferedOutputStream(new FileOutputStream(file));
+                    Writer writer = new OutputStreamWriter(output)) {
+                writer.write(content);
+            }
+        }
+    }
+
+    static String replaceProperties(String s, Map<String, String> properties) {
+        int i = 0;
+        StringBuilder sb = new StringBuilder();
+        while (i < s.length()) {
+            int next = s.indexOf("${", i);
+            if (next < 0) {
+                break;
+            }
+            int end = s.indexOf('}', next + 2);
+            if (end < 0) {
+                break;
+            }
+
+            sb.append(s, i, next);
+            String property = s.substring(next + 2, end);
+            String value = properties.get(property);
+            if (value == null) {
+                sb.append(s, next, end + 1);
+            } else {
+                sb.append(value);
+            }
+            i = end + 1;
+        }
+
+        if (i == 0) {
+            return s;
+        }
+
+        return sb.append(s.substring(i)).toString();
+    }
+
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/WasiPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/WasiPlatformSupport.java
new file mode 100644
index 000000000..f52b3a1fd
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/WasiPlatformSupport.java
@@ -0,0 +1,69 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static org.teavm.junit.PropertyNames.OPTIMIZED;
+import static org.teavm.junit.PropertyNames.WASI_ENABLED;
+import static org.teavm.junit.PropertyNames.WASI_RUNNER;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import org.teavm.backend.wasm.WasmRuntimeType;
+import org.teavm.backend.wasm.WasmTarget;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.ReferenceCache;
+
+class WasiPlatformSupport extends BaseWebAssemblyPlatformSupport {
+    WasiPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        super(classSource, referenceCache);
+    }
+
+    @Override
+    TestRunStrategy createRunStrategy(File outputDir) {
+        String wasiCommand = System.getProperty(WASI_RUNNER);
+        if (wasiCommand != null) {
+            return new WasiRunStrategy(wasiCommand);
+        }
+        return null;
+    }
+
+    @Override
+    protected WasmRuntimeType getRuntimeType() {
+        return WasmRuntimeType.WASI;
+    }
+
+    @Override
+    TestPlatform getPlatform() {
+        return TestPlatform.WASI;
+    }
+
+    @Override
+    String getPath() {
+        return "wasi";
+    }
+
+    @Override
+    List<TeaVMTestConfiguration<WasmTarget>> getConfigurations() {
+        List<TeaVMTestConfiguration<WasmTarget>> configurations = new ArrayList<>();
+        if (Boolean.getBoolean(WASI_ENABLED)) {
+            configurations.add(TeaVMTestConfiguration.WASM_DEFAULT);
+            if (Boolean.getBoolean(OPTIMIZED)) {
+                configurations.add(TeaVMTestConfiguration.WASM_OPTIMIZED);
+            }
+        }
+        return configurations;
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/WebAssemblyPlatformSupport.java b/tools/junit/src/main/java/org/teavm/junit/WebAssemblyPlatformSupport.java
new file mode 100644
index 000000000..c042234fa
--- /dev/null
+++ b/tools/junit/src/main/java/org/teavm/junit/WebAssemblyPlatformSupport.java
@@ -0,0 +1,92 @@
+/*
+ *  Copyright 2023 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.junit;
+
+import static org.teavm.junit.PropertyNames.OPTIMIZED;
+import static org.teavm.junit.PropertyNames.WASM_ENABLED;
+import static org.teavm.junit.PropertyNames.WASM_RUNNER;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import org.teavm.backend.wasm.WasmRuntimeType;
+import org.teavm.backend.wasm.WasmTarget;
+import org.teavm.model.ClassHolderSource;
+import org.teavm.model.MethodReference;
+import org.teavm.model.ReferenceCache;
+
+class WebAssemblyPlatformSupport extends BaseWebAssemblyPlatformSupport {
+    WebAssemblyPlatformSupport(ClassHolderSource classSource, ReferenceCache referenceCache) {
+        super(classSource, referenceCache);
+    }
+
+    @Override
+    TestRunStrategy createRunStrategy(File outputDir) {
+        var runStrategyName = System.getProperty(WASM_RUNNER);
+        if (runStrategyName != null) {
+            switch (runStrategyName) {
+                case "browser":
+                    return new BrowserRunStrategy(outputDir, "WASM", BrowserRunStrategy::customBrowser);
+                case "chrome":
+                case "browser-chrome":
+                    return new BrowserRunStrategy(outputDir, "WASM", BrowserRunStrategy::chromeBrowser);
+                case "browser-firefox":
+                    return new BrowserRunStrategy(outputDir, "WASM", BrowserRunStrategy::firefoxBrowser);
+                default:
+                    throw new RuntimeException("Unknown run strategy: " + runStrategyName);
+            }
+        }
+        return null;
+    }
+
+    @Override
+    TestPlatform getPlatform() {
+        return TestPlatform.WEBASSEMBLY;
+    }
+
+    @Override
+    String getPath() {
+        return "wasm";
+    }
+
+    @Override
+    List<TeaVMTestConfiguration<WasmTarget>> getConfigurations() {
+        List<TeaVMTestConfiguration<WasmTarget>> configurations = new ArrayList<>();
+        if (Boolean.getBoolean(WASM_ENABLED)) {
+            configurations.add(TeaVMTestConfiguration.WASM_DEFAULT);
+            if (Boolean.getBoolean(OPTIMIZED)) {
+                configurations.add(TeaVMTestConfiguration.WASM_OPTIMIZED);
+            }
+        }
+        return configurations;
+    }
+
+    @Override
+    protected WasmRuntimeType getRuntimeType() {
+        return WasmRuntimeType.TEAVM;
+    }
+
+    @Override
+    void additionalOutput(File outputPath, File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+        htmlOutput(outputPath, outputPathForMethod, configuration, reference, "teavm-run-test-wasm.html");
+    }
+
+    @Override
+    void additionalSingleTestOutput(File outputPathForMethod, TeaVMTestConfiguration<?> configuration,
+            MethodReference reference) {
+        htmlSingleTestOutput(outputPathForMethod, configuration, "teavm-run-test-wasm.html");
+    }
+}
diff --git a/tools/junit/src/main/java/org/teavm/junit/WholeClassCompilation.java b/tools/junit/src/main/java/org/teavm/junit/WholeClassCompilation.java
index 7de876c91..99d9798c8 100644
--- a/tools/junit/src/main/java/org/teavm/junit/WholeClassCompilation.java
+++ b/tools/junit/src/main/java/org/teavm/junit/WholeClassCompilation.java
@@ -22,5 +22,6 @@ import java.lang.annotation.Target;
 
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.TYPE)
+@Deprecated
 public @interface WholeClassCompilation {
 }