Introduction
I recently attended a webinar on testing applications built on Forge, led by @ianRagudo @KhanhNguyen and @LilyYang. They encouraged us to ask more detailed questions on the Atlassian Developer Community, so I’m taking them up on that suggestion. The focus was on unit and end-to-end (e2e) testing, primarily using jest.mock
to isolate dependencies. While this approach might be sufficient for small and simple applications, we often deal with more complex systems that consist of numerous internally cooperating components. Testing these components as a whole can be challenging and, frankly, not a best practice when aiming for a clean, testable architecture.
Our Approach
We usually follow a pattern based on dependency injection combined with the port-adapter architecture. It’s pretty much about decoupling our core logic from any third-party services or infrastructure. Why? Because it makes the code modular and testable. We’re talking about keeping things separate so that you can swap out dependencies without touching the main logic. It might sound a bit over-engineered for smaller stuff, but when things get big, it really pays off. Plus, this modular approach naturally supports A/B testing of different solutions, which is pretty useful when you’re experimenting with improvements. To be fair, figuring out how to run A/B tests in Forge itself is still a bit of a mystery for us, but that’s probably a whole separate topic to dig into.
To give a concrete example, think about a caching mechanism. In one of our Atlassian Connect apps, we use Redis for caching. But we don’t just hardwire Redis into the core logic. Instead, we define a general interface for caching. Then, we’ve got a RedisCache adapter implementing that interface. And for testing, we just swap it with an InMemoryCache or even a stub if we don’t need real data. This way, our business logic doesn’t care whether it’s Redis or something else - it just uses the interface.
Of course, we’re not saying that Redis itself is relevant for Forge - that’s not the point. The whole idea here is about making dependencies optional. Forge might have different caching options, and that’s totally fine. We just want to keep that flexibility.
Where We’re Stuck
So, during the webinar, I asked about building a testable architecture within Forge using similar patterns. But the answer was more about mocking and sticking to hardwired Forge’s infrastructure. I get that using Forge-specific stuff might be easier in some cases, but it feels a bit limiting, especially when aiming for clean and modular design. We’re really looking for advice on how to properly structure Forge apps to keep them modular and easy to test. Ideally, something that goes beyond just mocking stuff out.
The Question
Are there any best practices or recommendations from Atlassian on how to build a modular, testable architecture in Forge? Especially when it comes to using a port-adapter approach. Is that kind of architecture something you see as officially supported or planned for Forge in the future?
If you have any code samples, repos, or just high-level guidance, that would be great. Honestly, we’re open to experimenting on our own. Still, any tips or collaboration here would go a long way, especially since trying out different architectures can get a bit costly early on.
Would really appreciate your thoughts on this!
Example: Redis Cache in TypeScript
To give you an idea of what I mean by a modular setup, here’s a simplified TypeScript example:
// Cache Interface
interface Cache {
get: (key: string): Promise<string | null>;
set: (key: string, value: string): Promise<void>;
delete: (key: string): Promise<void>;
};
// Redis Cache Adapter
class RedisCache implements Cache {
async get(key: string): Promise<string | null> {
// Redis implementation
return null;
}
async set(key: string, value: string): Promise<void> {
// Redis implementation
}
async delete(key: string): Promise<void> {
// Redis implementation
}
}
// In-Memory Cache Adapter
class InMemoryCache implements Cache {
private store: Map<string, string> = new Map();
async get(key: string): Promise<string | null> {
return this.store.get(key) || null;
}
async set(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
}
// Stub Cache for Testing
class StubCache implements Cache {
async get(key: string): Promise<string | null> { return null; }
async set(key: string, value: string): Promise<void> {}
async delete(key: string): Promise<void> {}
}
// JiraUserApi interface and mock implementation
interface JiraUserApi {
getUser(userId: string): Promise<string>;
}
class JiraUserApiMock implements JiraUserApi {
async getUser(userId: string): Promise<string> {
return 'Mocked User';
}
}
// UserService dependent on Cache and JiraUserApi
class UserService {
constructor(private cache: Cache, private jiraUserApi: JiraUserApi) {}
async getUser(userId: string): Promise<string> {
const cachedUser = await this.cache.get(userId);
if (cachedUser) return cachedUser;
const user = await this.jiraUserApi.getUser(userId);
await this.cache.set(userId, user);
return user;
}
}
// Test example with Jest assertion
async function testUserService() {
const mockCache = new InMemoryCache();
const mockApi = new JiraUserApiMock();
const userService = new UserService(mockCache, mockApi);
await userService.getUser('123');
const cachedUser = await mockCache.get('123');
expect(cachedUser).toBe('Mocked User');
}
All Best