In any complex system, from a skyscraper to a space shuttle, reliability is paramount. This reliability isn't a magical property that appears at the end; it's painstakingly built in, piece by piece. Every bolt, every circuit, every line of code is tested to ensure it performs its specific function perfectly. When you build automated business workflows, the same principle applies. A workflow is only as strong as its weakest link.
This is where the concept of atomic actions becomes a game-changer. At action.do, we believe that the foundation of scalable, resilient automation lies in defining, triggering, and managing discrete, single-purpose tasks. But defining them is only half the battle. To build truly trustworthy systems, you must test them rigorously.
This guide will walk you through why testing your atomic actions isn't just a best practice—it's essential for building business processes you can depend on.
First, a quick refresher. An atomic action is the smallest, indivisible unit of work in a business process. It's a self-contained task that performs one job and performs it well.
The key is the word "atomic." Like an atom in the physical world, it's a fundamental building block. In computing, "atomicity" means an operation either succeeds completely or fails completely, leaving no partial or ambiguous state behind. This property is the bedrock of reliability. When an action fails, you know exactly which component failed, and you can trust that it didn't leave a mess for other parts of your system to clean up.
Imagine a simple workflow for onboarding a new customer: Create User -> Update CRM -> Send Welcome Email.
Now, imagine the Update CRM action has a subtle, untested bug. It fails silently when a user's name contains a special character. For weeks, everything seems fine. Then, a new user named "José O'Malley" signs up. The Update CRM action fails, the workflow halts, and José never gets his welcome email. He feels ignored, your support team gets a ticket, and a developer has to dig through logs to find the root cause.
This is the domino effect of untested actions. A small flaw in a single building block creates a cascading failure that can disrupt entire business processes, damage customer trust, and create hours of manual rework. The more complex your workflow—the more actions you chain together—the higher the probability of failure if the individual components aren't proven to be robust.
Testing atomic actions borrows heavily from the world of software unit testing. The goal is to verify each block in isolation before you assemble them into a larger structure. Here are the core principles.
An action must be testable without its surrounding workflow. This proves that the action's internal business logic is sound on its own. You should be able to trigger the action directly, provide it with inputs, and check its output, independent of any preceding or subsequent steps.
Every action on the .do platform is like a function: it accepts a structured set of inputs and returns a structured output. Your tests should confirm this contract.
It's not enough to confirm that your action works. You must also confirm that it fails predictably. When you simulate a failure (e.g., an external API is down), does your action return a { success: false, ... } payload? Does the error message clearly state what went wrong? Testing for failure is just as important as testing for success.
Most useful actions interact with the outside world—they call a database, query a third-party API (like Stripe or Google Maps), or trigger another internal service. Your tests should not make real network calls. This is slow, unreliable, and can have unintended consequences (like actually charging a credit card!).
Instead, you use mocks. A mock is a stand-in for a real dependency that you control within your test. It allows you to simulate the dependency's behavior—both success and failure—to confirm that your action interacts with it correctly.
Let's use the send-welcome-email action from our documentation. The definition looks like this:
import { Do } from '@do-inc/sdk';
const platform = new Do({ apiKey: 'YOUR_API_KEY' });
const sendWelcomeEmail = platform.action('send-welcome-email', {
description: 'Sends a welcome email to a new user.',
handler: async (inputs: { email: string, name: string }) => {
// Business logic to call an email service provider
console.log(`Sending welcome email to ${inputs.name} at ${inputs.email}`);
// In a real scenario, this would use an SDK like SendGrid or AWS SES
return { success: true, messageId: 'xyz-123' };
},
});
Now, let's write a few tests for this action's handler using a Jest-like syntax.
Here, we provide valid inputs and expect a successful outcome.
describe('sendWelcomeEmail handler', () => {
it('should return a success status with valid inputs', async () => {
const inputs = { email: 'alex@example.com', name: 'Alex' };
const result = await sendWelcomeEmail.handler(inputs);
expect(result.success).toBe(true);
expect(result.messageId).toBeDefined();
});
});
Here, we test that the action fails gracefully if required data is missing.
describe('sendWelcomeEmail handler', () => {
// ... previous test
it('should throw an error if email is missing', async () => {
const inputs = { name: 'Alex' }; // Missing email
// We expect the handler to throw an error, which the test will catch
await expect(sendWelcomeEmail.handler(inputs)).rejects.toThrow('Email is required');
});
});
Let's pretend our handler uses a hypothetical EmailService. We can mock this service to ensure our action calls it correctly without actually sending an email.
// At the top of our test file, we mock the service
const mockSend = jest.fn().mockResolvedValue({ id: 'xyz-123' });
jest.mock('./EmailService', () => {
return jest.fn().mockImplementation(() => {
return { send: mockSend };
});
});
describe('sendWelcomeEmail handler', () => {
// ... previous tests
it('should call the EmailService with the correct parameters', async () => {
const inputs = { email: 'alex@example.com', name: 'Alex' };
await sendWelcomeEmail.handler(inputs);
// Assert that our mock 'send' function was called once
expect(mockSend).toHaveBeenCalledTimes(1);
// Assert it was called with the right arguments
expect(mockSend).toHaveBeenCalledWith({
to: 'alex@example.com',
subject: 'Welcome, Alex!',
body: expect.any(String) // We don't need to test the exact body here
});
});
});
Testing your atomic actions isn't an afterthought; it's a core part of the development process. By building a suite of tests for these fundamental blocks, you create a safety net that catches bugs early, simplifies debugging, and allows you to compose complex workflows with confidence.
When each individual action is proven to be reliable, predictable, and resilient, the entire automated service you build on top of it inherits those qualities. This is how you move from fragile scripts to enterprise-grade Services-as-Software.
Ready to start building powerful, reliable business processes? Explore action.do and define your first atomic action today.
What is an 'atomic action' in the context of .do?
An atomic action is the smallest, indivisible unit of work in a business process. Think of it as a single, self-contained task like 'send an invoice,' 'update a CRM record,' or 'verify user identity.' These actions are the fundamental building blocks for creating services on the .do platform.
How is an action.do different from a full workflow?
An action.do represents a single step, while a workflow orchestrates multiple actions in a sequence or based on specific logic. You compose workflows by connecting various action.do agents together to model a complete business process and deliver a Service-as-Software.
Can actions accept inputs and produce outputs?
Yes. Every action is designed like a function. It accepts a structured set of inputs (e.g., customer details, order ID) and returns a structured output (e.g., a confirmation status, a user ID). This makes them predictable, reliable, and easy to integrate into larger systems.
What kind of tasks are suitable for an action.do?
Any discrete business task is a perfect candidate. Examples include sending notifications (email, SMS), performing a calculation, querying a database, calling an external API, or updating a record in a system of record. If you can define it as a single, repeatable function, you can build it as an action.do.