/* * Licensed to Elasticsearch under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch licenses this file to you 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.elasticsearch.test; import java.io.IOException; import java.lang.reflect.Modifier; import java.nio.file.FileVisitResult; import java.nio.file.FileVisitor; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; import java.util.HashSet; import java.util.Objects; import java.util.Set; /** * Checks that all tests in a directory are named according to our naming conventions. This is important because tests that do not follow * our conventions aren't run by gradle. This was once a glorious unit test but now that Elasticsearch is a multi-module project it must be * a class with a main method so gradle can call it for each project. This has the advantage of allowing gradle to calculate when it is * {@code UP-TO-DATE} so it can be skipped if the compiled classes haven't changed. This is useful on large modules for which checking all * the modules can be slow. * * Annoyingly, this cannot be tested using standard unit tests because to do so you'd have to declare classes that violate the rules. That * would cause the test fail which would prevent the build from passing. So we have to make a mechanism for removing those test classes. Now * that we have such a mechanism it isn't much work to fail the process if we don't detect the offending classes. Thus, the funky * {@code --self-test} that is only run in the test:framework project. */ public class NamingConventionsCheck { public static void main(String[] args) throws IOException { Class<?> testClass = null; Class<?> integTestClass = null; Path rootPath = null; boolean skipIntegTestsInDisguise = false; boolean selfTest = false; boolean checkMainClasses = false; for (int i = 0; i < args.length; i++) { String arg = args[i]; switch (arg) { case "--test-class": testClass = loadClassWithoutInitializing(args[++i]); break; case "--integ-test-class": integTestClass = loadClassWithoutInitializing(args[++i]); break; case "--skip-integ-tests-in-disguise": skipIntegTestsInDisguise = true; break; case "--self-test": selfTest = true; break; case "--main": checkMainClasses = true; break; case "--": rootPath = Paths.get(args[++i]); break; default: fail("unsupported argument '" + arg + "'"); } } NamingConventionsCheck check = new NamingConventionsCheck(testClass, integTestClass); if (checkMainClasses) { check.checkMain(rootPath); } else { check.checkTests(rootPath, skipIntegTestsInDisguise); } if (selfTest) { if (checkMainClasses) { assertViolation(NamingConventionsCheckInMainTests.class.getName(), check.testsInMain); assertViolation(NamingConventionsCheckInMainIT.class.getName(), check.testsInMain); } else { assertViolation("WrongName", check.missingSuffix); assertViolation("WrongNameTheSecond", check.missingSuffix); assertViolation("DummyAbstractTests", check.notRunnable); assertViolation("DummyInterfaceTests", check.notRunnable); assertViolation("InnerTests", check.innerClasses); assertViolation("NotImplementingTests", check.notImplementing); assertViolation("PlainUnit", check.pureUnitTest); } } // Now we should have no violations assertNoViolations( "Not all subclasses of " + check.testClass.getSimpleName() + " match the naming convention. Concrete classes must end with [Tests]", check.missingSuffix); assertNoViolations("Classes ending with [Tests] are abstract or interfaces", check.notRunnable); assertNoViolations("Found inner classes that are tests, which are excluded from the test runner", check.innerClasses); assertNoViolations("Pure Unit-Test found must subclass [" + check.testClass.getSimpleName() + "]", check.pureUnitTest); assertNoViolations("Classes ending with [Tests] must subclass [" + check.testClass.getSimpleName() + "]", check.notImplementing); assertNoViolations( "Classes ending with [Tests] or [IT] or extending [" + check.testClass.getSimpleName() + "] must be in src/test/java", check.testsInMain); if (skipIntegTestsInDisguise == false) { assertNoViolations( "Subclasses of " + check.integTestClass.getSimpleName() + " should end with IT as they are integration tests", check.integTestsInDisguise); } } private final Set<Class<?>> notImplementing = new HashSet<>(); private final Set<Class<?>> pureUnitTest = new HashSet<>(); private final Set<Class<?>> missingSuffix = new HashSet<>(); private final Set<Class<?>> integTestsInDisguise = new HashSet<>(); private final Set<Class<?>> notRunnable = new HashSet<>(); private final Set<Class<?>> innerClasses = new HashSet<>(); private final Set<Class<?>> testsInMain = new HashSet<>(); private final Class<?> testClass; private final Class<?> integTestClass; public NamingConventionsCheck(Class<?> testClass, Class<?> integTestClass) { this.testClass = Objects.requireNonNull(testClass, "--test-class is required"); this.integTestClass = integTestClass; } public void checkTests(Path rootPath, boolean skipTestsInDisguised) throws IOException { Files.walkFileTree(rootPath, new TestClassVisitor() { @Override protected void visitTestClass(Class<?> clazz) { if (skipTestsInDisguised == false && integTestClass.isAssignableFrom(clazz)) { integTestsInDisguise.add(clazz); } if (Modifier.isAbstract(clazz.getModifiers()) || Modifier.isInterface(clazz.getModifiers())) { notRunnable.add(clazz); } else if (isTestCase(clazz) == false) { notImplementing.add(clazz); } else if (Modifier.isStatic(clazz.getModifiers())) { innerClasses.add(clazz); } } @Override protected void visitIntegrationTestClass(Class<?> clazz) { if (isTestCase(clazz) == false) { notImplementing.add(clazz); } } @Override protected void visitOtherClass(Class<?> clazz) { if (Modifier.isAbstract(clazz.getModifiers()) || Modifier.isInterface(clazz.getModifiers())) { return; } if (isTestCase(clazz)) { missingSuffix.add(clazz); } else if (junit.framework.Test.class.isAssignableFrom(clazz)) { pureUnitTest.add(clazz); } } }); } public void checkMain(Path rootPath) throws IOException { Files.walkFileTree(rootPath, new TestClassVisitor() { @Override protected void visitTestClass(Class<?> clazz) { testsInMain.add(clazz); } @Override protected void visitIntegrationTestClass(Class<?> clazz) { testsInMain.add(clazz); } @Override protected void visitOtherClass(Class<?> clazz) { if (Modifier.isAbstract(clazz.getModifiers()) || Modifier.isInterface(clazz.getModifiers())) { return; } if (isTestCase(clazz)) { testsInMain.add(clazz); } } }); } /** * Fail the process if there are any violations in the set. Named to look like a junit assertion even though it isn't because it is * similar enough. */ private static void assertNoViolations(String message, Set<Class<?>> set) { if (false == set.isEmpty()) { System.err.println(message + ":"); for (Class<?> bad : set) { System.err.println(" * " + bad.getName()); } System.exit(1); } } /** * Fail the process if we didn't detect a particular violation. Named to look like a junit assertion even though it isn't because it is * similar enough. */ private static void assertViolation(String className, Set<Class<?>> set) { className = className.startsWith("org") ? className : "org.elasticsearch.test.NamingConventionsCheckBadClasses$" + className; if (false == set.remove(loadClassWithoutInitializing(className))) { System.err.println("Error in NamingConventionsCheck! Expected [" + className + "] to be a violation but wasn't."); System.exit(1); } } /** * Fail the process with the provided message. */ private static void fail(String reason) { System.err.println(reason); System.exit(1); } static Class<?> loadClassWithoutInitializing(String name) { try { return Class.forName(name, // Don't initialize the class to save time. Not needed for this test and this doesn't share a VM with any other tests. false, // Use our classloader rather than the bootstrap class loader. NamingConventionsCheck.class.getClassLoader()); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } abstract class TestClassVisitor implements FileVisitor<Path> { /** * The package name of the directory we are currently visiting. Kept as a string rather than something fancy because we load * just about every class and doing so requires building a string out of it anyway. At least this way we don't need to build the * first part of the string over and over and over again. */ private String packageName; /** * Visit classes named like a test. */ protected abstract void visitTestClass(Class<?> clazz); /** * Visit classes named like an integration test. */ protected abstract void visitIntegrationTestClass(Class<?> clazz); /** * Visit classes not named like a test at all. */ protected abstract void visitOtherClass(Class<?> clazz); @Override public final FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { // First we visit the root directory if (packageName == null) { // And it package is empty string regardless of the directory name packageName = ""; } else { packageName += dir.getFileName() + "."; } return FileVisitResult.CONTINUE; } @Override public final FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { // Go up one package by jumping back to the second to last '.' packageName = packageName.substring(0, 1 + packageName.lastIndexOf('.', packageName.length() - 2)); return FileVisitResult.CONTINUE; } @Override public final FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { String filename = file.getFileName().toString(); if (filename.endsWith(".class")) { String className = filename.substring(0, filename.length() - ".class".length()); Class<?> clazz = loadClassWithoutInitializing(packageName + className); if (clazz.getName().endsWith("Tests")) { visitTestClass(clazz); } else if (clazz.getName().endsWith("IT")) { visitIntegrationTestClass(clazz); } else { visitOtherClass(clazz); } } return FileVisitResult.CONTINUE; } /** * Is this class a test case? */ protected boolean isTestCase(Class<?> clazz) { return testClass.isAssignableFrom(clazz); } @Override public final FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { throw exc; } } }