Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example illustrating how more advanced method-to-method dependencies can be checked #1058

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.tngtech.archunit.example.singleton;

import java.util.concurrent.ConcurrentHashMap;

/**
* @author Per Lundberg
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally don't us the @author tag, since you can always see that from the commit history anyway...

*/
public class SingletonClass {

// Poor man's reimplementation of Guava's memoization support. If your project supports Guava,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would drop this comment. The example is about using ArchUnit, not how you should implement a singleton, given library x, right? 🤔 From my perspective you could even drop all code, leave it empty and just write comments // assume implemented, because the internals of this singleton or if it even works is irrelevant for the example / test, no? 🤔

// replace the stuff below with the following:
//
// private static final Supplier<SingletonClass> INSTANCE = Suppliers.memoize( SingletonClass::new );
//
//
// ...and retrieve the (singleton) instance by calling `INSTANCE.get()`
//
private static final ConcurrentHashMap<Integer, SingletonClass> INSTANCE_MAP = new ConcurrentHashMap<>();
private static final int INSTANCE_KEY = 1;


private SingletonClass() {
// Private constructor to prevent construction
}

public void doSomething() {
throw new UnsupportedOperationException();
}

public static SingletonClass getInstance() {
return INSTANCE_MAP.computeIfAbsent( INSTANCE_KEY, unused -> new SingletonClass() );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.tngtech.archunit.example.singleton;

/**
* An example of how {@link SingletonClass} is used incorrectly. This is expected to be detected by
* the corresponding ArchUnit test.
*
* @author Per Lundberg
*/
public class SingletonClassInvalidConsumer {

void doSomething() {
// This pattern (of calling getInstance() in the midst of a method) is both unadvisable and
// dangerous for the following reasons:
//
// - It makes it impossible to override the dependency for tests, which can lead to
// unnecessarily excessive object mocking.
//
// - It postpones object initialization to an unnecessarily late stage (runtime instead of
// startup-time). Problems with classes that cannot be instantiated risks being "hidden"
// until runtime, defeating the purpose of the "fail fast" philosophy.
SingletonClass.getInstance().doSomething();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.tngtech.archunit.example.singleton;

/**
* An example of how {@link SingletonClass} is used correctly
*
* @author Per Lundberg
*/
public class SingletonClassValidConsumer {

private static SingletonClassValidConsumer instance;

private final SingletonClass singletonClass;

public SingletonClassValidConsumer( SingletonClass singletonClass ) {
this.singletonClass = singletonClass;
}

public static SingletonClassValidConsumer getInstance() {
// Valid way to call getInstance() on another class:
//
// - We retrieve the instance for the dependency in our own getInstance() method. It is
// passed to our constructor as a constructor parameter, making it easy to override it for
// tests.
if ( instance == null ) {
instance = new SingletonClassValidConsumer( SingletonClass.getInstance() );
}

return instance;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.tngtech.archunit.example.singleton;

/**
* An example of how {@link SingletonClass} is used incorrectly, but explicitly whitelisted.
*
* @author Per Lundberg
*/
public class SingletonClassWhitelistedInvalidConsumer {

void doSomething() {
// This pattern (of calling getInstance() in the midst of a method) is both unadvisable and
// dangerous for reasons described in SingleTonClassInvalidConsumer.
SingletonClass.getInstance().doSomething();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.tngtech.archunit.exampletest;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;

import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;

import org.junit.Test;
import org.junit.experimental.categories.Category;

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.example.singleton.SingletonClass;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;

/**
* @author Per Lundberg
*/
@Category(Example.class)
public class SingletonTest {
private static final JavaClasses classes = new ClassFileImporter().importPackagesOf( SingletonClass.class);

@Test
public void getInstance_is_not_used_from_inside_methods() {
methods()
.that().haveName( "getInstance" )
.and().areStatic()

// Note: this is a convoluted way to say "no parameters".
.and().haveRawParameterTypes( new String[0] )

.should( onlyBeCalledFromWhitelistedOrigins(
// The class below will trigger a violation unless present as a parameter here.
"com.tngtech.archunit.example.singleton.SingletonClassWhitelistedInvalidConsumer"
) )
.because( "" +
"getInstance() calls should not be spread out through the methods of a class. This makes it hard/impossible " +
"to override the dependencies for tests, and also means the dependencies are much harder to identify when " +
"quickly looking at the code. Instead, move all getInstance() calls to the INSTANCE supplier and pass the " +
"dependency to the constructor that way. If this is impossible for a particular case, add the class name to " +
"the whitelist in " + getClass().getName() )
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"the whitelist in " + getClass().getName() )
"the whitelist in the `should` clause above"

.check( classes );
}

private ArchCondition<JavaMethod> onlyBeCalledFromWhitelistedOrigins( String... whitelistedOrigins ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically this is onlyBeCalledFromOtherStaticGetInstanceMethodsOrWhitelistedOrigins? 🤔

return new ArchCondition<JavaMethod>( "only be called by whitelisted methods" ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return new ArchCondition<JavaMethod>( "only be called by whitelisted methods" ) {
return new ArchCondition<JavaMethod>( "only be called from whitelisted origins" ) {

@Override
public void check( JavaMethod method, ConditionEvents events ) {
method.getCallsOfSelf().stream()
// TODO: Add your own exceptions as needed here, if you have particular
// TODO: use cases where getInstance() calls are permissible.
// Static getInstance() methods are always allowed to call getInstance. This
// does not break dependency injection and does not come with significant
// design flaws.
Comment on lines +55 to +59
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would drop this comment. It's just an example, it's clear that somebody needs to adjust this to their need. If you want to explain theory behind the test case itself I'd rather do it as Javadoc on the test class or test method.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise, the comments just make it harder to quickly get through the code 🤷‍♂️

.filter( call -> !( Objects.equals( call.getOrigin().getName(), "getInstance" ) && call.getOrigin().getModifiers().contains( JavaModifier.STATIC ) ) )
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe extract this as isNotFromStaticGetInstanceMethod(call)? 🤔


// Anything not handled by the exceptions above must be explicitly listed in
// the whitelistedOrigins parameter.
.filter( call -> {
Optional<String> result = Arrays.stream( whitelistedOrigins )
.filter( o -> call.getOrigin().getFullName().startsWith( o ) )
.findFirst();

return !result.isPresent();
} )
Comment on lines +64 to +70
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.filter( call -> {
Optional<String> result = Arrays.stream( whitelistedOrigins )
.filter( o -> call.getOrigin().getFullName().startsWith( o ) )
.findFirst();
return !result.isPresent();
} )
.filter(call -> Arrays.stream(whitelistedOrigins).noneMatch(it -> it.equals(call.getOriginOwner().getName())))

.forEach( call -> events.add( SimpleConditionEvent.violated( method, call.getDescription() ) ) );
}
};
}
}