codeWithYoha logo
Code with Yoha
HomeArticlesAboutContact
Node.js

Mastering Node.js Native Test Runner: A Viable Jest Alternative

CodeWithYoha
CodeWithYoha
20 min read
Mastering Node.js Native Test Runner: A Viable Jest Alternative

Introduction

For years, the Node.js ecosystem has relied heavily on third-party libraries like Jest, Mocha, and Vitest for testing. These tools have served the community well, providing rich feature sets, powerful assertion libraries, and excellent developer experience. However, with the release of Node.js v18 (and subsequent enhancements in v20+), the landscape of testing in Node.js underwent a significant transformation: the introduction of a built-in, native test runner.

This native test runner, accessible via the node:test module, aims to provide a lightweight, performant, and dependency-free solution for testing Node.js applications. It integrates seamlessly into the Node.js runtime, reducing project overhead and simplifying the toolchain. But can it truly stand as a viable alternative to established giants like Jest, especially for complex projects?

This article dives deep into the Node.js native test runner, exploring its features, demonstrating its usage with practical examples, and discussing its strengths and weaknesses compared to other popular frameworks. By the end, you'll have a clear understanding of when and how to leverage this powerful native tool to streamline your testing workflow.

Prerequisites

To follow along with this guide, you'll need:

  • Node.js v18.x or later: The native test runner was introduced in v18.0.0. For the mock module and enhanced features, Node.js v20.x or later is recommended.
  • Basic understanding of JavaScript and Node.js.
  • Familiarity with common testing concepts (unit tests, integration tests, assertions).

1. The Rise of Native Testing in Node.js

The node:test module was introduced to address several pain points in the Node.js testing landscape:

  • Dependency Bloat: Third-party test runners often come with a significant number of dependencies, increasing node_modules size and potential supply chain risks.
  • Setup Overhead: Configuring test runners, especially with transpilers or specific environments, can be complex.
  • Performance: Native integration can often offer performance advantages by avoiding overheads associated with external processes or complex setups.
  • Standardization: Providing a built-in solution offers a standardized approach to testing, making it easier for new developers to jump in.

It's designed to be familiar to users of tools like Mocha or Jest, adopting a similar describe/it (or test) syntax, making the transition relatively smooth.

2. Getting Started: Basic Setup

Using the native test runner is incredibly simple. There's no installation required, just import and use.

Let's create a simple utility function and its corresponding test file.

First, create src/math.js:

// src/math.js

/**
 * Adds two numbers.
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export function add(a, b) {
  return a + b;
}

/**
 * Subtracts two numbers.
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export function subtract(a, b) {
  return a - b;
}

/**
 * Multiplies two numbers.
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export function multiply(a, b) {
  return a * b;
}

Now, create test/math.test.js:

// test/math.test.js

import test from 'node:test';
import assert from 'node:assert';
import { add, subtract, multiply } from '../src/math.js';

test('Math operations', () => {
  test('should correctly add two numbers', () => {
    assert.strictEqual(add(1, 2), 3, 'Addition result should be 3');
    assert.strictEqual(add(-1, 1), 0, 'Addition result should be 0');
  });

  test('should correctly subtract two numbers', () => {
    assert.strictEqual(subtract(5, 2), 3, 'Subtraction result should be 3');
    assert.strictEqual(subtract(2, 5), -3, 'Subtraction result should be -3');
  });

  test('should correctly multiply two numbers', () => {
    assert.strictEqual(multiply(2, 3), 6, 'Multiplication result should be 6');
    assert.strictEqual(multiply(-2, 3), -6, 'Multiplication result should be -6');
  });

  test('should handle zero correctly in multiplication', () => {
    assert.strictEqual(multiply(5, 0), 0, 'Multiplication by zero should be zero');
  });
});

To run these tests, simply execute the test file using Node.js:

node --test test/math.test.js

Alternatively, you can run all test files in a directory (by default, files ending in .test.js, .spec.js, or containing test/ in their path) using:

node --test

This will discover and run all tests in your project that match the default patterns.

3. Test Structure and Organization

The node:test module provides a familiar API for structuring tests, reminiscent of Mocha or Jest. You primarily use the test function, which can be nested to create a hierarchical structure.

  • test(name, fn): Defines a test case.
  • test(name, options, fn): Defines a test case with additional options.

Let's refine our test/math.test.js to use nested test calls for better organization, similar to describe blocks:

// test/math.test.js - Refined structure

import test from 'node:test';
import assert from 'node:assert';
import { add, subtract, multiply } from '../src/math.js';

test('Math module', async (t) => { // 't' is the test context object

  await t.test('Addition operations', () => {
    assert.strictEqual(add(1, 2), 3, '1 + 2 should be 3');
    assert.strictEqual(add(-1, 1), 0, '-1 + 1 should be 0');
    assert.strictEqual(add(0.1, 0.2), 0.30000000000000004, 'Floating point addition can be tricky');
  });

  await t.test('Subtraction operations', () => {
    assert.strictEqual(subtract(5, 2), 3, '5 - 2 should be 3');
    assert.strictEqual(subtract(2, 5), -3, '2 - 5 should be -3');
  });

  await t.test('Multiplication operations', () => {
    assert.strictEqual(multiply(2, 3), 6, '2 * 3 should be 6');
    assert.strictEqual(multiply(-2, 3), -6, '-2 * 3 should be -6');
    assert.strictEqual(multiply(5, 0), 0, '5 * 0 should be 0');
  });
});

Notice the async (t) in the outer test block. While not strictly necessary for synchronous tests, it's good practice when nesting await t.test() calls. The t object (test context) passed to the test function is crucial for managing subtests, hooks, and other test-related functionalities.

4. Assertions with assert Module

The native test runner leverages Node.js's built-in node:assert module for assertions. This module provides a wide range of assertion functions, although its API might feel more verbose compared to Jest's expect matchers.

Commonly used assert functions include:

  • assert.strictEqual(actual, expected, message): Checks if actual is strictly equal to expected (===).
  • assert.deepStrictEqual(actual, expected, message): Checks if actual is deeply strictly equal to expected. Useful for objects and arrays.
  • assert.notStrictEqual(actual, expected, message): Checks for strict inequality.
  • assert.ok(value, message): Checks if value is truthy.
  • assert.throws(fn, error, message): Checks if fn throws an error.
  • assert.doesNotThrow(fn, error, message): Checks if fn does not throw an error.
  • assert.match(string, regexp, message): Checks if string matches regexp (Node.js v20+).
  • assert.rejects(asyncFn, error, message): Checks if asyncFn (a Promise) rejects with an error.

Example of different assertions:

// test/assertions.test.js

import test from 'node:test';
import assert from 'node:assert';

test('Various assertions', async (t) => {

  await t.test('Equality assertions', () => {
    assert.strictEqual(1, 1, 'Numbers should be strictly equal');
    assert.deepStrictEqual({ a: 1 }, { a: 1 }, 'Objects should be deeply strictly equal');
    assert.notStrictEqual(1, '1', 'Number 1 should not be strictly equal to string "1"');
  });

  await t.test('Truthiness and falsiness', () => {
    assert.ok(true, 'true should be truthy');
    assert.ok(1, '1 should be truthy');
    assert.ok('hello', 'Non-empty string should be truthy');
    assert.ok(!false, 'Not false should be truthy');
  });

  await t.test('Error handling assertions', () => {
    assert.throws(
      () => { throw new Error('Test Error'); },
      /Test Error/,
      'Should throw an error matching "Test Error"'
    );
    assert.doesNotThrow(
      () => { /* no error */ },
      'Should not throw any error'
    );
  });

  await t.test('Asynchronous error handling', async () => {
    const asyncFnThatRejects = async () => {
      return Promise.reject(new Error('Async Rejection'));
    };
    await assert.rejects(
      asyncFnThatRejects,
      /Async Rejection/,
      'Should reject with "Async Rejection"'
    );
  });

  await t.test('String matching (Node.js v20+)', () => {
    // Requires Node.js v20+
    assert.match('hello world', /hello/, 'String should contain "hello"');
  });
});

While functional, the assert module's error messages can sometimes be less descriptive than Jest's. For example, a deepStrictEqual failure might just show the diff without clear context if you don't provide a good message.

5. Asynchronous Testing

Node.js applications are inherently asynchronous. The native test runner handles asynchronous operations seamlessly using async/await and Promises.

When a test function returns a Promise (or is declared async), the test runner will wait for that Promise to resolve before marking the test as complete. If the Promise rejects, the test fails.

// src/dataService.js

export async function fetchData(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id, data: `Data for ${id}` });
    }, 100);
  });
}

export async function saveItem(item) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!item || !item.name) {
        return reject(new Error('Item name is required'));
      }
      resolve({ ...item, savedAt: new Date().toISOString() });
    }, 50);
  });
}
// test/async.test.js

import test from 'node:test';
import assert from 'node:assert';
import { fetchData, saveItem } from '../src/dataService.js';

test('Asynchronous operations', async (t) => {

  await t.test('fetchData should retrieve data successfully', async () => {
    const result = await fetchData(123);
    assert.deepStrictEqual(result, { id: 123, data: 'Data for 123' });
  });

  await t.test('saveItem should save an item successfully', async () => {
    const itemToSave = { name: 'New Widget', value: 99 };
    const result = await saveItem(itemToSave);
    assert.ok(result.savedAt, 'Saved item should have a timestamp');
    assert.strictEqual(result.name, 'New Widget', 'Item name should match');
  });

  await t.test('saveItem should reject if item name is missing', async () => {
    const invalidItem = { value: 10 };
    await assert.rejects(
      saveItem(invalidItem),
      /Item name is required/,
      'Should reject with "Item name is required" error'
    );
  });
});

This pattern is clean and intuitive, making asynchronous testing straightforward.

6. Before/After Hooks for Setup/Teardown

Just like other test runners, node:test provides hooks to perform setup and teardown operations before and after tests or test suites. These hooks are defined on the t (test context) object.

  • t.before(fn): Runs once before all subtests in the current test block.
  • t.after(fn): Runs once after all subtests in the current test block.
  • t.beforeEach(fn): Runs before each subtest in the current test block.
  • t.afterEach(fn): Runs after each subtest in the current test block.

These hooks can also be async functions.

// test/hooks.test.js

import test from 'node:test';
import assert from 'node:assert';

let sharedResource = null;
let counter = 0;

test('Test suite with hooks', async (t) => {

  t.before(async () => {
    // This runs once before all subtests within this 'Test suite with hooks' block
    console.log('Global setup: Initializing shared resource...');
    sharedResource = { dbConnection: 'mock_connection', data: [] };
    await new Promise(resolve => setTimeout(resolve, 50)); // Simulate async setup
    console.log('Global setup: Shared resource initialized.');
  });

  t.after(async () => {
    // This runs once after all subtests within this 'Test suite with hooks' block
    console.log('Global teardown: Cleaning up shared resource...');
    sharedResource = null;
    await new Promise(resolve => setTimeout(resolve, 20)); // Simulate async teardown
    console.log('Global teardown: Shared resource cleaned up.');
  });

  t.beforeEach(() => {
    // This runs before EACH subtest
    counter++;
    console.log(`  Before each test (${counter}): Resetting test-specific state.`);
  });

  t.afterEach(() => {
    // This runs after EACH subtest
    console.log(`  After each test (${counter}): Cleaning up test-specific state.`);
  });

  await t.test('Subtest 1: Should use shared resource', () => {
    assert.ok(sharedResource.dbConnection, 'DB connection should exist');
    sharedResource.data.push('item1');
    assert.strictEqual(sharedResource.data.length, 1);
    console.log('    Running Subtest 1');
  });

  await t.test('Subtest 2: Should add another item to shared resource', () => {
    assert.ok(sharedResource.dbConnection, 'DB connection should still exist');
    sharedResource.data.push('item2');
    assert.strictEqual(sharedResource.data.length, 2);
    console.log('    Running Subtest 2');
  });

  await t.test('Subtest 3: Async operation in test', async () => {
    await new Promise(resolve => setTimeout(resolve, 10));
    assert.ok(true, 'Async operation completed');
    console.log('    Running Subtest 3 (async)');
  });
});

This output clearly demonstrates the execution order of the hooks, ensuring proper test environment management.

7. Mocking and Spying (with mock module)

Mocking external dependencies is crucial for unit testing. Node.js v20+ introduced the experimental node:test/mock module, providing built-in capabilities for mocking functions and objects. This is a significant step towards parity with Jest's mocking features.

Key features of node:test/mock:

  • mock.fn(implementation): Creates a mock function.
  • mock.method(object, methodName, implementation): Mocks a method on an object.
  • mock.restoreAll(): Restores all mocked methods/functions. Crucial for cleanup.

Let's create a service that depends on an external API client.

// src/apiClient.js

export const apiClient = {
  async fetchUser(id) {
    // In a real app, this would make an HTTP request
    console.log(`Fetching user ${id} from real API...`);
    return new Promise(resolve => setTimeout(() => resolve({ id, name: `User ${id}`, email: `user${id}@example.com` }), 100));
  },
  async updateUser(id, data) {
    console.log(`Updating user ${id} in real API with data:`, data);
    return new Promise(resolve => setTimeout(() => resolve({ id, ...data, updated: true }), 50));
  }
};
// src/userService.js

import { apiClient } from './apiClient.js';

export class UserService {
  constructor(apiClientInstance) {
    this.apiClient = apiClientInstance || apiClient; // Allow injecting client
  }

  async getUserProfile(userId) {
    const user = await this.apiClient.fetchUser(userId);
    if (!user) {
      throw new Error('User not found');
    }
    return { id: user.id, displayName: user.name.toUpperCase() };
  }

  async updateUserName(userId, newName) {
    const updatedUser = await this.apiClient.updateUser(userId, { name: newName });
    return updatedUser;
  }
}

Now, let's test UserService by mocking apiClient.

// test/mocking.test.js

import test from 'node:test';
import assert from 'node:assert';
import { mock } from 'node:test'; // Requires Node.js v20+
import { UserService } from '../src/userService.js';
import { apiClient } from '../src/apiClient.js'; // The original dependency

test('UserService with mocking', async (t) => {

  // Restore all mocks after this test suite completes
  t.after(() => {
    mock.restoreAll();
  });

  await t.test('getUserProfile should return formatted user data', async () => {
    // Mock the fetchUser method of apiClient
    const fetchUserMock = mock.method(apiClient, 'fetchUser', async (id) => {
      assert.strictEqual(id, 1, 'fetchUser should be called with ID 1');
      return { id: 1, name: 'Test User', email: 'test@example.com' };
    });

    const userService = new UserService();
    const profile = await userService.getUserProfile(1);

    assert.strictEqual(profile.id, 1, 'Profile ID should match');
    assert.strictEqual(profile.displayName, 'TEST USER', 'Display name should be uppercase');
    assert.strictEqual(fetchUserMock.mock.callCount(), 1, 'fetchUser should have been called once');
  });

  await t.test('updateUserName should call updateUser on apiClient', async () => {
    const updateUserMock = mock.method(apiClient, 'updateUser', async (id, data) => {
      assert.strictEqual(id, 2, 'updateUser should be called with ID 2');
      assert.deepStrictEqual(data, { name: 'New Name' }, 'updateUser should be called with correct data');
      return { id: 2, name: 'New Name', updated: true };
    });

    const userService = new UserService();
    const updated = await userService.updateUserName(2, 'New Name');

    assert.strictEqual(updated.name, 'New Name', 'Updated name should be correct');
    assert.strictEqual(updateUserMock.mock.callCount(), 1, 'updateUser should have been called once');
  });

  await t.test('getUserProfile should throw error if user not found', async () => {
    const fetchUserMock = mock.method(apiClient, 'fetchUser', async (id) => {
      return null; // Simulate user not found
    });

    const userService = new UserService();
    await assert.rejects(
      userService.getUserProfile(999),
      /User not found/,
      'Should throw "User not found" error'
    );
    assert.strictEqual(fetchUserMock.mock.callCount(), 1, 'fetchUser should have been called once');
  });
});

The mock module provides a mock property on the mock function itself, which contains details like callCount, calls, results, etc., similar to Jest's mock functions. Remember to call mock.restoreAll() (or mock.restore() for specific mocks) to prevent test contamination.

8. Test Skipping and Focusing

Sometimes you need to temporarily skip a test or run only a specific test (or suite) during development. The native test runner supports this with options on the test function.

  • test(name, { skip: true }, fn): Skips the test.
  • test(name, { only: true }, fn): Runs only this test (and its subtests). All other tests will be skipped.
// test/skip-only.test.js

import test from 'node:test';
import assert from 'node:assert';

test('Main test suite', async (t) => {

  await t.test('This test will run', () => {
    assert.ok(true);
  });

  await t.test('This test will be skipped', { skip: true }, () => {
    assert.fail('This assertion should never be reached');
  });

  await t.test('This test will also run', () => {
    assert.strictEqual(1 + 1, 2);
  });

  await t.test('Only this block and its subtests will run', { only: true }, async (subT) => {
    await subT.test('Subtest A inside "only" block', () => {
      assert.strictEqual('a', 'a');
    });
    await subT.test('Subtest B inside "only" block', () => {
      assert.ok(true);
    });
  });

  await t.test('This test will be skipped because of "only: true" above', () => {
    assert.fail('This should not run');
  });
});

When you run this file with node --test test/skip-only.test.js, you'll see that only the tests marked with only: true (and its children) and tests not explicitly skipped will execute. If multiple only: true tests exist, only the first one encountered will run.

9. Watch Mode and Parallel Execution

The native test runner offers command-line flags for enhanced developer experience and performance:

  • Watch Mode: The --watch flag allows the test runner to re-execute tests whenever relevant files change. This is invaluable for Test-Driven Development (TDD).

    node --test --watch
  • Parallel Execution: By default, Node.js tests run in parallel across multiple worker threads, which can significantly speed up test execution, especially for large test suites. You can control the concurrency using the --test-concurrency flag. The default is the number of CPU cores minus 1, or 1 if there's only one core.

    node --test --test-concurrency=4 # Run with 4 parallel threads

    You can also disable parallelism with --test-concurrency=1.

  • Coverage: While not built into the node:test module itself, you can easily integrate with external coverage tools like c8 (which is a native-first coverage reporter).

    npm install --save-dev c8
    npx c8 node --test

    This will run your tests and generate a coverage report.

10. Comparing to Jest: When to Choose Native

So, when should you opt for the Node.js native test runner over a feature-rich framework like Jest?

Node.js Native Test Runner Pros:

  • Zero Dependencies: No npm install, no node_modules bloat, faster setup.
  • Built-in: Always available with Node.js, no versioning conflicts with external packages.
  • Fast Startup: Often quicker to start due to its native integration.
  • Simple API: Mimics popular test runners, making it easy to learn.
  • Parallel by Default: Leverages Node.js worker threads for efficient test execution.
  • Good for Microservices/Small Projects: Ideal for projects where minimizing dependencies is a priority.
  • ESM First: Works seamlessly with ES Modules without extra configuration.

Node.js Native Test Runner Cons:

  • Less Ergonomic Assertions: node:assert is powerful but can be more verbose than Jest's expect matchers. Custom matchers are not directly supported.
  • Mocking (v20+): The node:test/mock module is still experimental (as of Node.js 20) and less mature/feature-rich than Jest's comprehensive mocking system.
  • Snapshot Testing: No built-in snapshot testing (a popular Jest feature).
  • DOM Testing: Not designed for browser environments or DOM manipulation (Jest can be configured with JSDOM).
  • Limited Ecosystem: Fewer plugins, reporters, and integrations compared to Jest.
  • Transpilation: If you're using TypeScript or JSX, you'll need to handle transpilation separately (e.g., with ts-node, esbuild, or a build step), whereas Jest often has built-in transformers.

When to Choose Native:

  • New Node.js-only projects: Especially backend services or CLI tools.
  • Projects prioritizing minimal dependencies: Reduce supply chain risk and build times.
  • Teams comfortable with node:assert: Or willing to build lightweight custom assertion helpers.
  • Projects already using ES Modules: Native support shines here.
  • Microservices: Where each service can be tested independently with minimal overhead.

When to Stick with Jest (or alternatives like Vitest/Mocha):

  • Full-stack/Frontend projects: Requiring DOM testing or complex component testing.
  • Projects heavily reliant on snapshot testing: For UI or large data structure regression.
  • Existing large codebases: Where migrating a complex test suite would be too costly.
  • Teams requiring advanced mocking: For deep, specific mocking scenarios and spies.
  • TypeScript/JSX projects: Where integrated transpilation is a major convenience.
  • Need for a rich plugin ecosystem: Custom reporters, test environments, etc.

11. Integration with CI/CD Pipelines

Integrating the Node.js native test runner into CI/CD pipelines is straightforward due to its command-line interface and zero-dependency nature.

Here's an example of a .github/workflows/ci.yml for GitHub Actions:

# .github/workflows/ci.yml
name: Node.js CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x, 21.x]

    steps:
    - uses: actions/checkout@v4
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    - run: npm ci
    - name: Run Native Tests
      run: node --test
    - name: Run Coverage (optional, requires c8)
      run: npx c8 node --test
      env:
        NODE_V8_COVERAGE: ./coverage/tmp # Required for c8
    - name: Upload Coverage Report (optional)
      uses: actions/upload-artifact@v4
      with:
        name: coverage-report-${{ matrix.node-version }}
        path: coverage

This workflow demonstrates:

  1. Matrix Testing: Running tests against multiple Node.js versions.
  2. npm ci: Installing dependencies (if any, though node:test itself needs none).
  3. node --test: Executing all discovered tests.
  4. npx c8 node --test: Generating a coverage report (if c8 is installed).
  5. Artifact Upload: Storing coverage reports for later analysis.

The simplicity of node --test makes it a perfect fit for CI/CD environments, reducing build times and complexity.

Best Practices

  • Small, Focused Tests: Each test block should ideally verify one specific piece of functionality.
  • Clear Naming: Use descriptive names for your test blocks to indicate what they are testing.
  • Arrange-Act-Assert (AAA): Structure your tests with clear sections for setup (Arrange), execution (Act), and verification (Assert).
  • Separate Unit and Integration Tests: Organize your test files into different directories (e.g., test/unit, test/integration) and run them separately if needed.
  • Use t.after() for Cleanup: Always clean up resources (database connections, file handles, mocks) in t.after() or t.afterEach() hooks.
  • Inject Dependencies: Where possible, design your modules to accept dependencies (e.g., apiClient in UserService) rather than importing them directly. This makes mocking much easier.
  • Asynchronous Tests with async/await: Always use async/await for asynchronous tests to ensure proper waiting and error handling.

Common Pitfalls

  • Forgetting await: In async test functions, forgetting await for Promises or subtests can lead to tests passing prematurely or silently failing.
  • Not Restoring Mocks: If you use mock.method or mock.fn, ensure you call mock.restoreAll() (or mock.restore() for specific mocks) in an afterEach or after hook to prevent test pollution.
  • Over-Mocking: Mock only the immediate dependencies of the unit under test. Mocking too much can make tests brittle and hide real integration issues.
  • Poor Error Messages: assert messages can be generic. Provide a descriptive message argument to assert functions for clearer failure output.
  • Global State Contamination: Avoid modifying global state in tests without proper cleanup in afterEach hooks. This is a common source of flaky tests.
  • Running Tests Without --test Flag: If you just run node test/my.test.js, the test runner features (like parallel execution, hook management) won't be active; it will just execute the file as a regular script.

Conclusion

The Node.js native test runner is a powerful, lightweight, and increasingly mature tool that offers a compelling alternative to traditional third-party testing frameworks. While it may not yet have the full feature parity of a highly evolved tool like Jest (especially in areas like snapshot testing or advanced custom matchers), its core capabilities for unit and integration testing are robust and highly effective.

For new Node.js-centric projects, microservices, or environments where minimizing dependencies and maximizing native performance are key, the node:test module is an excellent choice. Its seamless integration, familiar API, and built-in parallelism make it a joy to work with. As Node.js continues to evolve, we can expect the native test runner to gain even more features and become an indispensable part of the Node.js developer's toolkit.

Embrace the native capabilities of Node.js and give its built-in test runner a try. You might find that the simplicity and efficiency it offers are exactly what your next project needs. Happy testing!

Younes Hamdane

Written by

Younes Hamdane

Full-Stack Software Engineer with 5+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

Related Articles