In the world of automation, complexity is the enemy of reliability. We've all seen it: a sprawling, monolithic script where a single, unexpected API error can bring an entire business process to a grinding halt. Debugging becomes a nightmare, and confidence in the system plummets. But what if we could build our automations not as fragile monoliths, but as resilient structures assembled from individually perfected, unbreakable components?
This is the core philosophy behind the .do platform and the concept of atomic actions. An atomic action is the smallest, indivisible unit of work—your fundamental building block for automation. By encapsulating a single task like send-welcome-email or create-user-in-db into a self-contained block, you pave the way for creating powerful, flexible, and robust agentic workflows.
But a building block is only as strong as its quality control. To truly achieve the promise of "Business as Code," we must apply the same rigor to our actions as we do to our production application code. That means one thing: testing.
Before we dive into the "how," let's solidify the "why." Why is testing individual actions so critical when building larger workflows?
Theory is great, but let's get practical. How do you effectively test an action? The key is to test the core logic within the handler function.
Let's start with the send-welcome-email action defined in our documentation:
import { Action } from '@do-sdk/core';
// Define a new Action to send a welcome email
const sendWelcomeEmail = new Action({
name: 'send-welcome-email',
description: 'Sends a standardized welcome email to a new user.',
handler: async (inputs: { email: string; name:string }) => {
console.log(`Preparing to send email to ${inputs.email}...`);
// Logic to connect to an email service (e.g., SendGrid, SES)
// const emailSent = await emailService.send({ ... });
const result = { success: true, messageId: `msg_${Date.now()}` };
console.log('Email sent successfully:', result.messageId);
return result;
},
});
To test this effectively, we need to be able to run the handler's logic without actually calling an external email service. This would be slow, costly, and unreliable for an automated test suite. The best practice for this is Dependency Injection.
We'll make a small but powerful change: instead of creating the emailService inside the handler, we'll expect it to be passed in. The .do platform can provide dependencies like this via a context object during runtime.
// /actions/send-welcome-email.action.ts
import { Action } from '@do-sdk/core';
import { IEmailService } from '../services/email'; // Assume an interface for our service
export const sendWelcomeEmail = new Action({
name: 'send-welcome-email',
description: 'Sends a standardized welcome email to a new user.',
handler: async (
inputs: { email: string; name: string },
// The context object provides dependencies at runtime
context: { emailService: IEmailService }
) => {
console.log(`Preparing to send email via action to ${inputs.email}...`);
const result = await context.emailService.send({
to: inputs.email,
subject: `Welcome, ${inputs.name}!`,
body: 'We are so glad you joined us...',
});
console.log('Email sent successfully:', result.messageId);
return { success: true, messageId: result.messageId };
},
});
This action is now decoupled from the concrete implementation of the email service, making it perfectly testable.
Now, using a testing framework like Jest, we can write a test file. We'll create a "mock" version of our emailService that mimics the real one without making network calls.
// /actions/send-welcome-email.test.ts
import { sendWelcomeEmail } from './send-welcome-email.action';
import { IEmailService } from '../services/email';
// 1. Create a mock email service that we control
const mockEmailService: IEmailService = {
send: jest.fn(),
};
// 2. Describe the test suite for our action
describe('Action: send-welcome-email', () => {
// Clear mock history before each test
beforeEach(() => {
(mockEmailService.send as jest.Mock).mockClear();
});
// Test the "happy path"
it('should call the email service with correct details and return success', async () => {
// Arrange: Set up the inputs and the mock's return value
const inputs = { email: 'alex@example.com', name: 'Alex' };
(mockEmailService.send as jest.Mock).mockResolvedValue({ messageId: 'msg_12345' });
// Act: Execute the action's handler, passing our mock service in the context
const result = await sendWelcomeEmail.handler(inputs, { emailService: mockEmailService });
// Assert: Check if the mock was used as expected
expect(mockEmailService.send).toHaveBeenCalledTimes(1);
expect(mockEmailService.send).toHaveBeenCalledWith({
to: 'alex@example.com',
subject: 'Welcome, Alex!',
body: expect.any(String),
});
expect(result.success).toBe(true);
expect(result.messageId).toBe('msg_12345');
});
// Test a failure case
it('should throw an error if the email service fails', async () => {
// Arrange: Set up the inputs and make the mock reject the call
const inputs = { email: 'fail@example.com', name: 'Failure' };
const apiError = new Error('Invalid API Key');
(mockEmailService.send as jest.Mock).mockRejectedValue(apiError);
// Act & Assert: Verify that the handler propagates the error
// The .do platform will catch this and handle the action's failure state.
await expect(
sendWelcomeEmail.handler(inputs, { emailService: mockEmailService })
).rejects.toThrow('Invalid API Key');
expect(mockEmailService.send).toHaveBeenCalledTimes(1);
});
});
With this test suite, you can run it in milliseconds as part of your CI/CD pipeline, guaranteeing that your send-welcome-email action behaves exactly as expected before it ever gets deployed.
Atomic actions are the future of building scalable and resilient API automation and intelligent systems. They provide the modularity needed for complex workflow orchestration. But their true power is only unlocked when each action is a bastion of reliability.
By embracing testing as a core part of your development process, you ensure that every building block you create is solid, predictable, and ready to be composed into something extraordinary.
Ready to build your first reliable action? Get started on the .do platform and turn your business logic into testable, scalable, and powerful code.
Q: What is an 'atomic action' on the .do platform?
A: An atomic action is the smallest, indivisible unit of work in a workflow. It's a self-contained, reusable function designed to perform a single task reliably, like 'send an email', 'create a user', or 'query a database'.
Q: How are Actions different from traditional serverless functions?
A: Actions on .do are supercharged functions. They are automatically instrumented with logging, error handling, retries, and versioning. They are designed to be discovered and composed into larger workflows, effectively turning your business logic into manageable code.
Q: Can an Action call other Actions?
A: While Actions are designed to be atomic, complex logic is best handled by orchestrating multiple Actions within a Workflow (workflow.do). This promotes modularity, reusability, and a clearer separation of concerns in your automation architecture.
Q: What kind of tasks can I build as an Action?
A: Virtually any task you can script. Common examples include interacting with third-party APIs (e.g., Stripe, Slack, Salesforce), performing database operations, running data transformations, or executing machine learning model inferences. If you can code it, you can make it an Action.