Sus Testing Framework

repository·main·Indexed 41 days ago

https://github.com/socketry/sus

A high-performance, parallel testing framework for Ruby featuring a BDD-style `expect` syntax with first-class predicates. Sus provides tools for lifecycle management via `before`, `after`, and `around` blocks, a robust mocking system with `receive` and `mock`, and reusable test behaviors through `Sus::Shared` fixtures.

Tokens
13.5K
Snippets
54
Records
67
Agent score
51%

What's inside Sus

  1. Overview of Sus testing framework

    main

    Sus is a testing framework for Ruby designed to be a lightweight, highly parallel alternative to RSpec.

    Key features include:

    • expect style syntax with first-class predicates.
    • Direct support for code coverage (via the covered gem).
    • Support for the VSCode Test Runner interface (via sus-vscode).
    • Extensibility through fixture gems (e.g., sus-fixtures-*).
    • High parallelism for faster test execution.
  2. Project structure for Sus

    main

    While the structure can vary, Sus expects a standard layout where test/ contains your test files and fixtures/ is available in the $LOAD_PATH. A configuration file is typically placed in config/sus.rb.

    my-gem/
    ├── config/
    │   └── sus.rb                     # Sus configuration file
    ├── lib/
    │   ├── my_gem.rb
    │   └── my_gem/
    │       └── my_thing.rb
    ├── fixtures/
    │   └── my_gem/
    │       └── a_thing.rb               # Provides MyGem::AThing shared context
    └── test/
        ├── my_gem.rb                    # Tests MyGem
        └── my_gem/
            └── my_thing.rb              # Tests MyGem::MyThing
  3. How method call expectations work with `receive`

    main

    The receive(:method) matcher is used to set expectations that a specific method will be called on an object. It is a subset of full mocking and is used for verification rather than replacing implementation.

    Key characteristics:

    • Non-sequenced: receive does not enforce the order of method calls. If you need to enforce call order, use mock instead.
    • Scope: Mocking non-local objects permanently changes the object's ancestors. To avoid this, use let to define local objects and mock those instead.

    Use receive to:

    • Verify a method is called.
    • Verify specific arguments are passed.
    • Specify return values.
    • Simulate exceptions.
    • Verify the number of times a method is called.
    expect(logger).to receive(:info)
  4. How shared fixtures work in Sus

    main

    Shared fixtures allow you to define common test behaviors once and reuse them across multiple test files or multiple implementations of an interface. This is useful for code reuse, ensuring consistency across interface implementations, and parameterizing tests.

    There are two main ways to organize shared behaviors:

    1. Across multiple test files: Store these in the fixtures/ directory. The fixtures/ directory is automatically added to the $LOAD_PATH.
    2. Within a single test file: Define them as shared contexts directly within that file to apply to different implementations of the same interface.

    Shared fixtures are created using Sus::Shared and can accept parameters to make the tests flexible.

    # Example of a shared fixture structure
    
    # fixtures/my_gem/a_user.rb
    module MyGem
      AUser = Sus::Shared("a user") do |role|
        let(:user) { { name: "Test", role: role } }
        it "has a role" do
          expect(user[:role]).to be_a(String)
        end
      end
    end
    
    # test/my_gem/user_manager.rb
    require "my_gem/a_user"
    describe MyGem::UserManager do
      it_behaves_like MyGem::AUser, "manager"
    end
  5. How shared fixtures and shared contexts work in sus

    main

    Sus provides two ways to reuse test logic depending on the scope of the reuse:

    1. Shared Fixtures: Used for common behaviors applied across multiple test files. These are stored in the fixtures/ directory, which is automatically added to the $LOAD_PATH. They are typically defined using Sus::Shared and organized to mirror your project structure.

    2. Shared Contexts: Used for common behaviors applied to multiple implementations of the same interface within a single test file. These are defined locally within the test file using Sus::Shared or via standard Ruby modules.

    Both mechanisms allow you to reduce duplication, ensure consistency across implementations, and parameterize tests with different inputs.

    # Example of the directory structure for shared fixtures
    my-gem/
    ├── lib/
    │   └── my_gem/
    │       └── my_thing.rb
    ├── fixtures/
    │   └── my_gem/
    │       └── a_thing.rb               # Provides MyGem::AThing shared context
    └── test/
        └── my_gem/
            └── my_thing.rb
  6. Use `with` for context and method testing

    main

    The with block allows you to create specific contexts. You can pass non-lazy state as keyword arguments. It is also useful for specifying which method is being tested (instance or class methods) to improve test readability.

    # Context with keyword arguments
    with "invalid input", input: nil do
    	it "raises an error" do
    		expect{my_thing.process(input)}.to raise_exception(ArgumentError)
    	end
    end
    
    # Testing specific methods
    with "#my_method" do
    	it "returns a value" do
    		expect(my_thing.my_method).to be == 42
    	end
    end
    
    with ".my_class_method" do
    	it "returns a value" do
    		expect(MyThing.class_method).to be == "class value"
    	end
    end
  7. How to write tests with Sus

    main

    Sus uses a BDD-style syntax. You can group tests using describe and create context-specific groups using with. Individual test cases are defined with it blocks.

    • describe: Groups related tests around a class or object.
    • it: Defines an individual test case.
    • with: Creates a context, often used to specify input or the method being tested (e.g., with "#method_name").
    describe MyProject::MyClass do
      let(:instance) {subject.new}
      
      it "instantiates an object" do
        expect(instance).to be_a(Object)
      end
    end
    
    with "valid input", input: "data" do
      it "succeeds" do
        expect(result).to be_truthy
      end
    end
  8. Replace method implementations with `mock`

    main

    The mock(object) { |mock| ... } block is used to replace method implementations or set up complex behaviors. Unlike receive, mock can be used to intercept calls and provide custom logic.

    Important Constraints:

    • Thread Safety: Mock objects are thread-local. They only affect the current thread and are not suitable for multi-threaded tests.

    Replacing a method

    Use mock.replace(:method_name) { |args| ... } to define a new implementation.

    mock(http_client) do |mock|
      mock.replace(:get) do |url, headers: {}|
        # custom logic here
        "response_body"
      end
    end

    Advanced patterns

    • mock.before { |...| ... }: Execute code before the original method.
    • mock.after { |...| ... }: Execute code after the original method.
    • mock.wrap(:method) { |original, ...| original.call(...) }: Wrap the original method implementation.
    describe ApiClient do
    	let(:http_client) {Object.new}
    	let(:client) {ApiClient.new(http_client)}
    	let(:users) {["Alice", "Bob"]}
    	
    	it "fetches users from API" do
    		mock(http_client) do |mock|
    			mock.replace(:get) do |url, headers: {}|
    				expect(url).to be == "/api/users"
    				expect(headers).to be == {"accept" => "application/json"}
    				users.to_json
    			end
    		end
    		
    		expect(client.fetch_users).to be == users
    	end
    end
  9. Use shared fixtures in tests

    main

    Once a shared fixture is defined, you can use it in your describe blocks using two primary methods:

    1. it_behaves_like: Used to apply the shared behavior and pass arguments to the fixture.
    2. include_context: An alias for it_behaves_like.
    3. include: Used within a with block when the shared fixture does not require arguments.

    To use a fixture from the fixtures/ directory, you must first require it.

    # Using it_behaves_like with arguments
    require "my_gem/a_user"
    
    describe MyGem::UserManager do
      it_behaves_like MyGem::AUser, "manager"
      # or
      # include_context MyGem::AUser, "manager"
    end
    
    # Using include within a 'with' block (no arguments)
    require "my_gem/users"
    
    describe MyGem::Authorization do
      with "standard user" do
        include MyGem::Users::AStandardUser
        
        it "denies admin access" do
          auth = subject.new
          expect(auth.can_admin?(user)).to be_falsey
        end
      end
    end
  10. Enumerate tests for multiple implementations

    main

    To run the same set of shared tests against multiple different implementations (e.g., different database adapters), you can iterate over a collection of implementations and wrap the it_behaves_like call in a describe block.

    Important: Use the unique: option in the describe block, passing a unique identifier (like the adapter name). This ensures that if a test fails, the error report points to the specific implementation that failed rather than showing the same line number for every iteration.

    require "sus/shared"
    
    ADatabaseAdapter = Sus::Shared("a database adapter") do |adapter|
    	let(:database) {adapter.new}
    	
    	it "connects to the database" do
    		expect(database.connect).to be_truthy
    	end
    end
    
    # Enumerate the tests with different adapters
    MyGem::DatabaseAdapters.each do |adapter|
    	describe "with #{adapter}", unique: adapter.name do
    		it_behaves_like ADatabaseAdapter, adapter
    	end
    end