Repository: dominikbraun/graph
Pull request #69 (2022-11-25 15:26:52)

Return defined error instances from Graph methods


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0bf1629..3ac9b06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),  and this project
 adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.15.0] - 2022-11-25
+
+### Added
+* Added the `ErrVertexAlreadyExists` error instance. Use `errors.Is` to check for this instance.
+* Added the `ErrEdgeAlreadyExists` error instance. Use `errors.Is` to check for this instance.
+* Added the `ErrEdgeCreatesCycle` error instance. Use `errors.Is` to check for this instance.
+
+### Changed
+* Changed `AddVertex` to return `ErrVertexAlreadyExists` if the vertex already exists.
+* Changed `VertexWithProperties` to return `ErrVertexNotFound` if the vertex doesn't exist.
+* Changed `AddEdge` to return `ErrVertexNotFound` if either vertex doesn't exist.
+* Changed `AddEdge` to return `ErrEdgeAlreadyExists` if the edge already exists.
+* Changed `AddEdge` to return `ErrEdgeCreatesCycle` if cycle prevention is active and the edge would create a cycle.
+* Changed `Edge` to return `ErrEdgeNotFound` if the edge doesn't exist.
+* Changed `RemoveEdge` to return the error instances returned by `Edge`.
+
 ## [0.14.0] - 2022-11-01
 
 ### Added
diff --git a/directed.go b/directed.go
index adcb946..8d67680 100644
--- a/directed.go
+++ b/directed.go
@@ -33,6 +33,11 @@ func (d *directed[K, T]) Traits() *Traits {
 
 func (d *directed[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error {
 	hash := d.hash(value)
+
+	if _, ok := d.vertices[hash]; ok {
+		return ErrVertexAlreadyExists
+	}
+
 	d.vertices[hash] = value
 	d.vertexProperties[hash] = &VertexProperties{
 		Weight:     0,
@@ -63,7 +68,7 @@ func (d *directed[K, T]) VertexWithProperties(hash K) (T, VertexProperties, erro
 
 	properties, ok := d.vertexProperties[hash]
 	if !ok {
-		return vertex, *properties, fmt.Errorf("vertex with hash %v doesn't exist", hash)
+		return vertex, *properties, ErrVertexNotFound
 	}
 
 	return vertex, *properties, nil
@@ -72,26 +77,26 @@ func (d *directed[K, T]) VertexWithProperties(hash K) (T, VertexProperties, erro
 func (d *directed[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error {
 	source, ok := d.vertices[sourceHash]
 	if !ok {
-		return fmt.Errorf("could not find source vertex with hash %v", sourceHash)
+		return fmt.Errorf("source vertex %v: %w", sourceHash, ErrVertexNotFound)
 	}
 
 	target, ok := d.vertices[targetHash]
 	if !ok {
-		return fmt.Errorf("could not find target vertex with hash %v", targetHash)
+		return fmt.Errorf("target vertex %v: %w", targetHash, ErrVertexNotFound)
 	}
 
 	if _, err := d.Edge(sourceHash, targetHash); !errors.Is(err, ErrEdgeNotFound) {
-		return fmt.Errorf("an edge between vertices %v and %v already exists", sourceHash, targetHash)
+		return ErrEdgeAlreadyExists
 	}
 
 	// If the user opted in to preventing cycles, run a cycle check.
 	if d.traits.PreventCycles {
 		createsCycle, err := CreatesCycle[K, T](d, sourceHash, targetHash)
 		if err != nil {
-			return fmt.Errorf("failed to check for cycles: %w", err)
+			return fmt.Errorf("check for cycles: %w", err)
 		}
 		if createsCycle {
-			return fmt.Errorf("an edge between %v and %v would introduce a cycle", sourceHash, targetHash)
+			return ErrEdgeCreatesCycle
 		}
 	}
 
@@ -128,7 +133,7 @@ func (d *directed[K, T]) Edge(sourceHash, targetHash K) (Edge[T], error) {
 
 func (d *directed[K, T]) RemoveEdge(source, target K) error {
 	if _, err := d.Edge(source, target); err != nil {
-		return fmt.Errorf("failed to find edge from %v to %v: %w", source, target, err)
+		return err
 	}
 
 	delete(d.edges[source], target)
diff --git a/graph.go b/graph.go
index 6eed6cd..deb31bd 100644
--- a/graph.go
+++ b/graph.go
@@ -5,10 +5,11 @@ package graph
 import "errors"
 
 var (
-	// ErrVertexNotFound will be returned when a desired vertex cannot be found.
-	ErrVertexNotFound = errors.New("vertex not found")
-	// ErrEdgeNotFound will be returned when a desired edge cannot be found.
-	ErrEdgeNotFound = errors.New("edge not found")
+	ErrVertexNotFound      = errors.New("vertex not found")
+	ErrVertexAlreadyExists = errors.New("vertex already exists")
+	ErrEdgeNotFound        = errors.New("edge not found")
+	ErrEdgeAlreadyExists   = errors.New("edge already exists")
+	ErrEdgeCreatesCycle    = errors.New("edge would create a cycle")
 )
 
 // Graph represents a generic graph data structure consisting of vertices and edges. Its vertices
@@ -17,11 +18,8 @@ type Graph[K comparable, T any] interface {
 	// Traits returns the graph's traits. Those traits must be set when creating a graph using New.
 	Traits() *Traits
 
-	// AddVertex creates a new vertex in the graph, which won't be connected to another vertex yet.
-	//
-	// Whether AddVertex is idempotent depends on the underlying vertex store implementation. By
-	// default, when using the in-memory store, an existing vertex will be overwritten, whereas
-	// other stores might return an error.
+	// AddVertex creates a new vertex in the graph. If the vertex already exists in the graph,
+	// ErrVertexAlreadyExists will be returned.
 	//
 	// AddVertex accepts a variety of functional options to set further edge details such as the
 	// weight or an attribute:
@@ -33,13 +31,15 @@ type Graph[K comparable, T any] interface {
 	// Vertex returns the vertex with the given hash or ErrVertexNotFound if it doesn't exist.
 	Vertex(hash K) (T, error)
 
-	// VertexWithProperties returns the vertex with the given hash along with its properties, or an
-	// error if the vertex doesn't exist.
+	// VertexWithProperties returns the vertex with the given hash along with its properties or
+	// ErrVertexNotFound if it doesn't exist.
 	VertexWithProperties(hash K) (T, VertexProperties, error)
 
 	// AddEdge creates an edge between the source and the target vertex. If the Directed option has
-	// been called on the graph, this is a directed edge. Returns an error if either vertex doesn't
-	// exist or the edge already exists.
+	// been called on the graph, this is a directed edge. If either vertex can't be found,
+	// ErrVertexNotFound will be returned. If the edge already exists, ErrEdgeAlreadyExists will be
+	// returned. If cycle prevention has been activated using PreventCycles and adding the edge
+	// would create a cycle, ErrEdgeCreatesCycle will be returned.
 	//
 	// AddEdge accepts a variety of functional options to set further edge details such as the
 	// weight or an attribute:
@@ -49,7 +49,8 @@ type Graph[K comparable, T any] interface {
 	AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error
 
 	// Edge returns the edge joining two given vertices or an error if the edge doesn't exist. In an
-	// undirected graph, an edge with swapped source and target vertices does match.
+	// undirected graph, an edge with swapped source and target vertices does match. If the edge
+	// doesn't exist, ErrEdgeNotFound will be returned.
 	Edge(sourceHash, targetHash K) (Edge[T], error)
 
 	// RemoveEdge removes the edge between the given source and target vertices. If the edge doesn't
diff --git a/undirected.go b/undirected.go
index ddea03d..1affc5b 100644
--- a/undirected.go
+++ b/undirected.go
@@ -31,6 +31,11 @@ func (u *undirected[K, T]) Traits() *Traits {
 
 func (u *undirected[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error {
 	hash := u.hash(value)
+
+	if _, ok := u.vertices[hash]; ok {
+		return ErrVertexAlreadyExists
+	}
+
 	u.vertices[hash] = value
 	u.vertexProperties[hash] = &VertexProperties{
 		Weight:     0,
@@ -61,7 +66,7 @@ func (u *undirected[K, T]) VertexWithProperties(hash K) (T, VertexProperties, er
 
 	properties, ok := u.vertexProperties[hash]
 	if !ok {
-		return vertex, *properties, fmt.Errorf("vertex with hash %v doesn't exist", hash)
+		return vertex, *properties, ErrVertexNotFound
 	}
 
 	return vertex, *properties, nil
@@ -70,26 +75,26 @@ func (u *undirected[K, T]) VertexWithProperties(hash K) (T, VertexProperties, er
 func (u *undirected[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error {
 	source, ok := u.vertices[sourceHash]
 	if !ok {
-		return fmt.Errorf("could not find source vertex with hash %v", sourceHash)
+		return fmt.Errorf("source vertex %v: %w", sourceHash, ErrVertexNotFound)
 	}
 
 	target, ok := u.vertices[targetHash]
 	if !ok {
-		return fmt.Errorf("could not find target vertex with hash %v", targetHash)
+		return fmt.Errorf("target vertex %v: %w", targetHash, ErrVertexNotFound)
 	}
 
 	if _, err := u.Edge(sourceHash, targetHash); !errors.Is(err, ErrEdgeNotFound) {
-		return fmt.Errorf("an edge between vertices %v and %v already exists", sourceHash, targetHash)
+		return ErrEdgeAlreadyExists
 	}
 
 	// If the user opted in to preventing cycles, run a cycle check.
 	if u.traits.PreventCycles {
 		createsCycle, err := CreatesCycle[K, T](u, sourceHash, targetHash)
 		if err != nil {
-			return fmt.Errorf("failed to check for cycles: %w", err)
+			return fmt.Errorf("check for cycles: %w", err)
 		}
 		if createsCycle {
-			return fmt.Errorf("an edge between %v and %v would introduce a cycle", sourceHash, targetHash)
+			return ErrEdgeCreatesCycle
 		}
 	}
 
@@ -131,7 +136,7 @@ func (u *undirected[K, T]) Edge(sourceHash, targetHash K) (Edge[T], error) {
 
 func (u *undirected[K, T]) RemoveEdge(source, target K) error {
 	if _, err := u.Edge(source, target); err != nil {
-		return fmt.Errorf("failed to find edge from %v to %v: %w", source, target, err)
+		return err
 	}
 
 	delete(u.inEdges[source], target)
@@ -210,7 +215,7 @@ func (u *undirected[K, T]) Size() int {
 		size += len(outEdges)
 	}
 
-	// Divide by 2 since every add edge operation on undirected graph is counted twice.
+	// Divide by 2 since every AddEdge call on undirected graphs is counted twice.
 	return size / 2
 }
 


Test patch:
diff --git a/directed_test.go b/directed_test.go
index f6b2cb7..dff535e 100644
--- a/directed_test.go
+++ b/directed_test.go
@@ -1,6 +1,7 @@
 package graph
 
 import (
+	"errors"
 	"testing"
 )
 
@@ -35,6 +36,9 @@ func TestDirected_AddVertex(t *testing.T) {
 		properties         *VertexProperties
 		expectedVertices   []int
 		expectedProperties *VertexProperties
+		// Even though some AddVertex calls might work, at least one of them could fail, for example
+		// if the last call would add an existing vertex.
+		finallyExpectedError error
 	}{
 		"graph with 3 vertices": {
 			vertices: []int{1, 2, 3},
@@ -49,26 +53,33 @@ func TestDirected_AddVertex(t *testing.T) {
 			},
 		},
 		"graph with duplicated vertex": {
-			vertices:         []int{1, 2, 2},
-			expectedVertices: []int{1, 2},
+			vertices:             []int{1, 2, 2},
+			expectedVertices:     []int{1, 2},
+			finallyExpectedError: ErrVertexAlreadyExists,
 		},
 	}
 
 	for name, test := range tests {
 		graph := newDirected(IntHash, &Traits{})
 
+		var err error
+
 		for _, vertex := range test.vertices {
 			if test.properties == nil {
-				_ = graph.AddVertex(vertex)
+				err = graph.AddVertex(vertex)
 				continue
 			}
 			// If there are vertex attributes, iterate over them and call VertexAttribute for each
 			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
 			for key, value := range test.properties.Attributes {
-				_ = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
+				err = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
 			}
 		}
 
+		if !errors.Is(err, test.finallyExpectedError) {
+			t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.finallyExpectedError, err)
+		}
+
 		for _, vertex := range test.vertices {
 			if len(graph.vertices) != len(test.expectedVertices) {
 				t.Errorf("%s: vertex count doesn't match: expected %v, got %v", name, len(test.expectedVertices), len(graph.vertices))
@@ -104,15 +115,55 @@ func TestDirected_AddVertex(t *testing.T) {
 	}
 }
 
+func TestDirected_Vertex(t *testing.T) {
+	tests := map[string]struct {
+		vertices      []int
+		vertex        int
+		expectedError error
+	}{
+		"existing vertex": {
+			vertices: []int{1, 2, 3},
+			vertex:   2,
+		},
+		"non-existent vertex": {
+			vertices:      []int{1, 2, 3},
+			vertex:        4,
+			expectedError: ErrVertexNotFound,
+		},
+	}
+
+	for name, test := range tests {
+		graph := newDirected(IntHash, &Traits{})
+
+		for _, vertex := range test.vertices {
+			_ = graph.AddVertex(vertex)
+		}
+
+		vertex, err := graph.Vertex(test.vertex)
+
+		if !errors.Is(err, test.expectedError) {
+			t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.expectedError, err)
+		}
+
+		if test.expectedError != nil {
+			continue
+		}
+
+		if vertex != test.vertex {
+			t.Errorf("%s: vertex expectancy doesn't match: expected %v, got %v", name, test.vertex, vertex)
+		}
+	}
+}
+
 func TestDirected_AddEdge(t *testing.T) {
 	tests := map[string]struct {
 		vertices      []int
 		edges         []Edge[int]
 		traits        *Traits
 		expectedEdges []Edge[int]
-		// Even though some of the AddEdge calls might work, at least one of them could fail,
-		// for example if the last call would introduce a cycle.
-		shouldFinallyFail bool
+		// Even though some AddEdge calls might work, at least one of them could fail, for example
+		// if the last call would introduce a cycle.
+		finallyExpectedError error
 	}{
 		"graph with 2 edges": {
 			vertices: []int{1, 2, 3},
@@ -131,8 +182,8 @@ func TestDirected_AddEdge(t *testing.T) {
 			edges: []Edge[int]{
 				{Source: 1, Target: 3, Properties: EdgeProperties{Weight: 20}},
 			},
-			traits:            &Traits{},
-			shouldFinallyFail: true,
+			traits:               &Traits{},
+			finallyExpectedError: ErrVertexNotFound,
 		},
 		"edge introducing a cycle in an acyclic graph": {
 			vertices: []int{1, 2, 3},
@@ -144,7 +195,18 @@ func TestDirected_AddEdge(t *testing.T) {
 			traits: &Traits{
 				PreventCycles: true,
 			},
-			shouldFinallyFail: true,
+			finallyExpectedError: ErrEdgeCreatesCycle,
+		},
+		"edge already exists": {
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2},
+				{Source: 2, Target: 3},
+				{Source: 3, Target: 1},
+				{Source: 3, Target: 1},
+			},
+			traits:               &Traits{},
+			finallyExpectedError: ErrEdgeAlreadyExists,
 		},
 		"edge with attributes": {
 			vertices: []int{1, 2},
@@ -197,8 +259,8 @@ func TestDirected_AddEdge(t *testing.T) {
 			}
 		}
 
-		if test.shouldFinallyFail != (err != nil) {
-			t.Fatalf("%s: error expectancy doesn't match: expected %v, got %v (error: %v)", name, test.shouldFinallyFail, (err != nil), err)
+		if !errors.Is(err, test.finallyExpectedError) {
+			t.Fatalf("%s: error expectancy doesn't match: expected %v, got %v", name, test.finallyExpectedError, err)
 		}
 
 		for _, expectedEdge := range test.expectedEdges {
@@ -258,6 +320,7 @@ func TestDirected_Edge(t *testing.T) {
 
 	for name, test := range tests {
 		graph := newDirected(IntHash, &Traits{})
+
 		for _, vertex := range test.vertices {
 			_ = graph.AddVertex(vertex)
 		}
@@ -277,9 +340,10 @@ func TestDirected_Edge(t *testing.T) {
 
 func TestDirected_RemoveEdge(t *testing.T) {
 	tests := map[string]struct {
-		vertices    []int
-		edges       []Edge[int]
-		removeEdges []Edge[int]
+		vertices      []int
+		edges         []Edge[int]
+		removeEdges   []Edge[int]
+		expectedError error
 	}{
 		"two-vertices graph": {
 			vertices: []int{1, 2},
@@ -302,6 +366,16 @@ func TestDirected_RemoveEdge(t *testing.T) {
 				{Source: 2, Target: 3},
 			},
 		},
+		"remove non-existent edge": {
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2},
+			},
+			removeEdges: []Edge[int]{
+				{Source: 2, Target: 3},
+			},
+			expectedError: ErrEdgeNotFound,
+		},
 	}
 
 	for name, test := range tests {
@@ -318,8 +392,8 @@ func TestDirected_RemoveEdge(t *testing.T) {
 		}
 
 		for _, removeEdge := range test.removeEdges {
-			if err := graph.RemoveEdge(removeEdge.Source, removeEdge.Target); err != nil {
-				t.Fatalf("%s: failed to remove edge: %s", name, err.Error())
+			if err := graph.RemoveEdge(removeEdge.Source, removeEdge.Target); !errors.Is(err, test.expectedError) {
+				t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.expectedError, err)
 			}
 			// After removing the edge, verify that it can't be retrieved using Edge anymore.
 			if _, err := graph.Edge(removeEdge.Source, removeEdge.Target); err != ErrEdgeNotFound {
diff --git a/undirected_test.go b/undirected_test.go
index f56781a..7161652 100644
--- a/undirected_test.go
+++ b/undirected_test.go
@@ -1,6 +1,7 @@
 package graph
 
 import (
+	"errors"
 	"testing"
 )
 
@@ -35,6 +36,9 @@ func TestUndirected_AddVertex(t *testing.T) {
 		properties         *VertexProperties
 		expectedVertices   []int
 		expectedProperties *VertexProperties
+		// Even though some AddVertex calls might work, at least one of them could fail, for example
+		// if the last call would add an existing vertex.
+		finallyExpectedError error
 	}{
 		"graph with 3 vertices": {
 			vertices: []int{1, 2, 3},
@@ -49,26 +53,33 @@ func TestUndirected_AddVertex(t *testing.T) {
 			},
 		},
 		"graph with duplicated vertex": {
-			vertices:         []int{1, 2, 2},
-			expectedVertices: []int{1, 2},
+			vertices:             []int{1, 2, 2},
+			expectedVertices:     []int{1, 2},
+			finallyExpectedError: ErrVertexAlreadyExists,
 		},
 	}
 
 	for name, test := range tests {
 		graph := newUndirected(IntHash, &Traits{})
 
+		var err error
+
 		for _, vertex := range test.vertices {
 			if test.properties == nil {
-				_ = graph.AddVertex(vertex)
+				err = graph.AddVertex(vertex)
 				continue
 			}
 			// If there are vertex attributes, iterate over them and call VertexAttribute for each
 			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
 			for key, value := range test.properties.Attributes {
-				_ = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
+				err = graph.AddVertex(vertex, VertexWeight(test.properties.Weight), VertexAttribute(key, value))
 			}
 		}
 
+		if err != test.finallyExpectedError {
+			t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.finallyExpectedError, err)
+		}
+
 		for _, vertex := range test.vertices {
 			if len(graph.vertices) != len(test.expectedVertices) {
 				t.Errorf("%s: vertex count doesn't match: expected %v, got %v", name, len(test.expectedVertices), len(graph.vertices))
@@ -104,38 +115,124 @@ func TestUndirected_AddVertex(t *testing.T) {
 	}
 }
 
+func TestUndirected_Vertex(t *testing.T) {
+	tests := map[string]struct {
+		vertices      []int
+		vertex        int
+		expectedError error
+	}{
+		"existing vertex": {
+			vertices: []int{1, 2, 3},
+			vertex:   2,
+		},
+		"non-existent vertex": {
+			vertices:      []int{1, 2, 3},
+			vertex:        4,
+			expectedError: ErrVertexNotFound,
+		},
+	}
+
+	for name, test := range tests {
+		graph := newUndirected(IntHash, &Traits{})
+
+		for _, vertex := range test.vertices {
+			_ = graph.AddVertex(vertex)
+		}
+
+		vertex, err := graph.Vertex(test.vertex)
+
+		if err != test.expectedError {
+			t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.expectedError, err)
+		}
+
+		if test.expectedError != nil {
+			continue
+		}
+
+		if vertex != test.vertex {
+			t.Errorf("%s: vertex expectancy doesn't match: expected %v, got %v", name, test.vertex, vertex)
+		}
+	}
+}
+
 func TestUndirected_AddEdge(t *testing.T) {
 	tests := map[string]struct {
 		vertices      []int
-		edgeHashes    [][3]int
+		edges         []Edge[int]
 		traits        *Traits
 		expectedEdges []Edge[int]
 		// Even though some AddEdge calls might work, at least one of them could fail, for example
 		// if the last call would introduce a cycle.
-		shouldFinallyFail bool
+		finallyExpectedError error
 	}{
 		"graph with 2 edges": {
-			vertices:   []int{1, 2, 3},
-			edgeHashes: [][3]int{{1, 2, 10}, {1, 3, 20}},
-			traits:     &Traits{},
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2, Properties: EdgeProperties{Weight: 10}},
+				{Source: 1, Target: 3, Properties: EdgeProperties{Weight: 20}},
+			},
+			traits: &Traits{},
 			expectedEdges: []Edge[int]{
 				{Source: 1, Target: 2, Properties: EdgeProperties{Weight: 10}},
 				{Source: 1, Target: 3, Properties: EdgeProperties{Weight: 20}},
 			},
 		},
 		"hashes for non-existent vertices": {
-			vertices:          []int{1, 2},
-			edgeHashes:        [][3]int{{1, 3, 20}},
-			traits:            &Traits{},
-			shouldFinallyFail: true,
+			vertices: []int{1, 2},
+			edges: []Edge[int]{
+				{Source: 1, Target: 3, Properties: EdgeProperties{Weight: 20}},
+			},
+			traits:               &Traits{},
+			finallyExpectedError: ErrVertexNotFound,
 		},
 		"edge introducing a cycle in an acyclic graph": {
-			vertices:   []int{1, 2, 3},
-			edgeHashes: [][3]int{{1, 2, 0}, {2, 3, 0}, {3, 1, 0}},
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2},
+				{Source: 2, Target: 3},
+				{Source: 3, Target: 1},
+			},
 			traits: &Traits{
 				PreventCycles: true,
 			},
-			shouldFinallyFail: true,
+			finallyExpectedError: ErrEdgeCreatesCycle,
+		},
+		"edge already exists": {
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2},
+				{Source: 2, Target: 3},
+				{Source: 3, Target: 1},
+				{Source: 3, Target: 1},
+			},
+			traits:               &Traits{},
+			finallyExpectedError: ErrEdgeAlreadyExists,
+		},
+		"edge with attributes": {
+			vertices: []int{1, 2},
+			edges: []Edge[int]{
+				{
+					Source: 1,
+					Target: 2,
+					Properties: EdgeProperties{
+						Attributes: map[string]string{
+							"color": "red",
+						},
+					},
+				},
+			},
+			expectedEdges: []Edge[int]{
+				{
+					Source: 1,
+					Target: 2,
+					Properties: EdgeProperties{
+						Attributes: map[string]string{
+							"color": "red",
+						},
+					},
+				},
+			},
+			traits: &Traits{},
 		},
 	}
 
@@ -148,14 +245,22 @@ func TestUndirected_AddEdge(t *testing.T) {
 
 		var err error
 
-		for _, edge := range test.edgeHashes {
-			if err = graph.AddEdge(edge[0], edge[1], EdgeWeight(edge[2])); err != nil {
+		for _, edge := range test.edges {
+			if len(edge.Properties.Attributes) == 0 {
+				err = graph.AddEdge(edge.Source, edge.Target, EdgeWeight(edge.Properties.Weight))
+			}
+			// If there are edge attributes, iterate over them and call EdgeAttribute for each
+			// entry. An edge should only have one attribute so that AddEdge is invoked once.
+			for key, value := range edge.Properties.Attributes {
+				err = graph.AddEdge(edge.Source, edge.Target, EdgeWeight(edge.Properties.Weight), EdgeAttribute(key, value))
+			}
+			if err != nil {
 				break
 			}
 		}
 
-		if test.shouldFinallyFail != (err != nil) {
-			t.Fatalf("%s: error expectancy doesn't match: expected %v, got %v (error: %v)", name, test.shouldFinallyFail, (err != nil), err)
+		if !errors.Is(err, test.finallyExpectedError) {
+			t.Fatalf("%s: error expectancy doesn't match: expected %v, got %v", name, test.finallyExpectedError, err)
 		}
 
 		for _, expectedEdge := range test.expectedEdges {
@@ -178,6 +283,20 @@ func TestUndirected_AddEdge(t *testing.T) {
 			if edge.Properties.Weight != expectedEdge.Properties.Weight {
 				t.Errorf("%s: edge weights don't match: expected weight %v, got %v", name, expectedEdge.Properties.Weight, edge.Properties.Weight)
 			}
+
+			if len(edge.Properties.Attributes) != len(expectedEdge.Properties.Attributes) {
+				t.Fatalf("%s: attributes length don't match: expcted %v, got %v", name, len(expectedEdge.Properties.Attributes), len(edge.Properties.Attributes))
+			}
+
+			for expectedKey, expectedValue := range expectedEdge.Properties.Attributes {
+				value, ok := edge.Properties.Attributes[expectedKey]
+				if !ok {
+					t.Errorf("%s: attribute keys don't match: expected key %v not found", name, expectedKey)
+				}
+				if value != expectedValue {
+					t.Errorf("%s: attribute values don't match: expected value %v for key %v, got %v", name, expectedValue, expectedKey, value)
+				}
+			}
 		}
 	}
 }
@@ -222,9 +341,10 @@ func TestUndirected_Edge(t *testing.T) {
 
 func TestUndirected_RemoveEdge(t *testing.T) {
 	tests := map[string]struct {
-		vertices    []int
-		edges       []Edge[int]
-		removeEdges []Edge[int]
+		vertices      []int
+		edges         []Edge[int]
+		removeEdges   []Edge[int]
+		expectedError error
 	}{
 		"two-vertices graph": {
 			vertices: []int{1, 2},
@@ -247,6 +367,16 @@ func TestUndirected_RemoveEdge(t *testing.T) {
 				{Source: 2, Target: 3},
 			},
 		},
+		"remove non-existent edge": {
+			vertices: []int{1, 2, 3},
+			edges: []Edge[int]{
+				{Source: 1, Target: 2},
+			},
+			removeEdges: []Edge[int]{
+				{Source: 2, Target: 3},
+			},
+			expectedError: ErrEdgeNotFound,
+		},
 	}
 
 	for name, test := range tests {
@@ -263,8 +393,8 @@ func TestUndirected_RemoveEdge(t *testing.T) {
 		}
 
 		for _, removeEdge := range test.removeEdges {
-			if err := graph.RemoveEdge(removeEdge.Source, removeEdge.Target); err != nil {
-				t.Fatalf("%s: failed to remove edge: %s", name, err.Error())
+			if err := graph.RemoveEdge(removeEdge.Source, removeEdge.Target); !errors.Is(err, test.expectedError) {
+				t.Errorf("%s: error expectancy doesn't match: expected %v, got %v", name, test.expectedError, err)
 			}
 			// After removing the edge, verify that it can't be retrieved using Edge anymore.
 			if _, err := graph.Edge(removeEdge.Source, removeEdge.Target); err != ErrEdgeNotFound {